From 929bfd3dfce6d40057eba9d78b2e8d44f3d4c0e2 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 26 Apr 2023 15:31:35 -0500 Subject: [PATCH 001/822] Bump intents to 2023.4.26 (#92070) Co-authored-by: Franck Nijhof --- 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 9b1f111d5525..0221d80002c1 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["hassil==1.0.6", "home-assistant-intents==2023.4.17-1"] + "requirements": ["hassil==1.0.6", "home-assistant-intents==2023.4.26"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 8b9539cd8e92..52429425462a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -26,7 +26,7 @@ hass-nabucasa==0.66.2 hassil==1.0.6 home-assistant-bluetooth==1.10.0 home-assistant-frontend==20230426.0 -home-assistant-intents==2023.4.17-1 +home-assistant-intents==2023.4.26 httpx==0.24.0 ifaddr==0.1.7 janus==1.0.0 diff --git a/requirements_all.txt b/requirements_all.txt index d79390f5a88e..93782c840472 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -914,7 +914,7 @@ holidays==0.21.13 home-assistant-frontend==20230426.0 # homeassistant.components.conversation -home-assistant-intents==2023.4.17-1 +home-assistant-intents==2023.4.26 # homeassistant.components.home_connect homeconnect==0.7.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b94fd57291a4..8b8d7dfaa254 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -703,7 +703,7 @@ holidays==0.21.13 home-assistant-frontend==20230426.0 # homeassistant.components.conversation -home-assistant-intents==2023.4.17-1 +home-assistant-intents==2023.4.26 # homeassistant.components.home_connect homeconnect==0.7.2 From b5edf47454b039078ac2be037e215641d6ccb019 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 26 Apr 2023 22:58:26 +0200 Subject: [PATCH 002/822] Bump version to 2023.6.0dev0 (#92081) --- .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 6fad65734461..30a3f6f4f3eb 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -32,7 +32,7 @@ env: CACHE_VERSION: 5 PIP_CACHE_VERSION: 4 MYPY_CACHE_VERSION: 4 - HA_SHORT_VERSION: 2023.5 + HA_SHORT_VERSION: 2023.6 DEFAULT_PYTHON: "3.10" ALL_PYTHON_VERSIONS: "['3.10', '3.11']" # 10.3 is the oldest supported version diff --git a/homeassistant/const.py b/homeassistant/const.py index 23b4a9a13293..4f1586fc3032 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from .backports.enum import StrEnum APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 -MINOR_VERSION: Final = 5 +MINOR_VERSION: Final = 6 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 8a0781e08a94..ab938e56b4c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.5.0.dev0" +version = "2023.6.0.dev0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From fdfd567ee52c1957396426e9d35957cea63f9a59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Wed, 26 Apr 2023 23:01:22 +0200 Subject: [PATCH 003/822] Bump pylaunches from 1.3.0 to 1.4.0 (#92061) --- homeassistant/components/launch_library/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/launch_library/manifest.json b/homeassistant/components/launch_library/manifest.json index 3816027a0cce..778e5634b8c0 100644 --- a/homeassistant/components/launch_library/manifest.json +++ b/homeassistant/components/launch_library/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/launch_library", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["pylaunches==1.3.0"] + "requirements": ["pylaunches==1.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 93782c840472..be8f818acf02 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1746,7 +1746,7 @@ pylacrosse==0.4 pylast==5.1.0 # homeassistant.components.launch_library -pylaunches==1.3.0 +pylaunches==1.4.0 # homeassistant.components.lg_netcast pylgnetcast==0.3.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8b8d7dfaa254..1d3d2b3fc885 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1268,7 +1268,7 @@ pykulersky==0.5.2 pylast==5.1.0 # homeassistant.components.launch_library -pylaunches==1.3.0 +pylaunches==1.4.0 # homeassistant.components.forked_daapd pylibrespot-java==0.1.1 From 29ca43acf648372c19bb0ce3cd8bc81a4595a8e1 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 27 Apr 2023 10:29:08 +1200 Subject: [PATCH 004/822] Set pipeline_id from pipeline select (#92085) --- homeassistant/components/esphome/__init__.py | 2 +- .../components/esphome/voice_assistant.py | 18 ++++++- .../esphome/test_voice_assistant.py | 47 ++++++++++++------- 3 files changed, 47 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index 3bec7f883d0a..6ce5f656d6e8 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -301,7 +301,7 @@ async def async_setup_entry( # noqa: C901 if voice_assistant_udp_server is not None: return None - voice_assistant_udp_server = VoiceAssistantUDPServer(hass) + voice_assistant_udp_server = VoiceAssistantUDPServer(hass, entry_data) port = await voice_assistant_udp_server.start_server() hass.async_create_background_task( diff --git a/homeassistant/components/esphome/voice_assistant.py b/homeassistant/components/esphome/voice_assistant.py index 9b35fc7972aa..b6c76e00f4c5 100644 --- a/homeassistant/components/esphome/voice_assistant.py +++ b/homeassistant/components/esphome/voice_assistant.py @@ -14,10 +14,13 @@ from homeassistant.components.assist_pipeline import ( PipelineEvent, PipelineEventType, async_pipeline_from_audio_stream, + select as pipeline_select, ) from homeassistant.components.media_player import async_process_play_media_url from homeassistant.core import Context, HomeAssistant, callback +from .const import DOMAIN +from .entry_data import RuntimeEntryData from .enum_mapper import EsphomeEnumMapper _LOGGER = logging.getLogger(__name__) @@ -48,10 +51,18 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): queue: asyncio.Queue[bytes] | None = None transport: asyncio.DatagramTransport | None = None - def __init__(self, hass: HomeAssistant) -> None: + def __init__( + self, + hass: HomeAssistant, + entry_data: RuntimeEntryData, + ) -> None: """Initialize UDP receiver.""" self.context = Context() self.hass = hass + + assert entry_data.device_info is not None + self.device_info = entry_data.device_info + self.queue = asyncio.Queue() async def start_server(self) -> int: @@ -155,7 +166,7 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): context=self.context, event_callback=handle_pipeline_event, stt_metadata=stt.SpeechMetadata( - language="", + language="", # set in async_pipeline_from_audio_stream format=stt.AudioFormats.WAV, codec=stt.AudioCodecs.PCM, bit_rate=stt.AudioBitRates.BITRATE_16, @@ -163,4 +174,7 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): 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 + ), ) diff --git a/tests/components/esphome/test_voice_assistant.py b/tests/components/esphome/test_voice_assistant.py index ee6f4f7289fc..e1fe41829c2b 100644 --- a/tests/components/esphome/test_voice_assistant.py +++ b/tests/components/esphome/test_voice_assistant.py @@ -8,6 +8,8 @@ import async_timeout import pytest from homeassistant.components import assist_pipeline, esphome +from homeassistant.components.esphome import DomainData +from homeassistant.components.esphome.voice_assistant import VoiceAssistantUDPServer from homeassistant.core import HomeAssistant _TEST_INPUT_TEXT = "This is an input test" @@ -15,7 +17,19 @@ _TEST_OUTPUT_TEXT = "This is an output test" _TEST_OUTPUT_URL = "output.mp3" -async def test_pipeline_events(hass: HomeAssistant) -> None: +@pytest.fixture +def voice_assistant_udp_server_v1( + hass: HomeAssistant, + mock_voice_assistant_v1_entry, +) -> VoiceAssistantUDPServer: + """Return the UDP server.""" + entry_data = DomainData.get(hass).get_entry_data(mock_voice_assistant_v1_entry) + return VoiceAssistantUDPServer(hass, entry_data) + + +async def test_pipeline_events( + hass: HomeAssistant, voice_assistant_udp_server_v1: VoiceAssistantUDPServer +) -> None: """Test that the pipeline function is called.""" async def async_pipeline_from_audio_stream(*args, **kwargs): @@ -67,75 +81,74 @@ async def test_pipeline_events(hass: HomeAssistant) -> None: "homeassistant.components.esphome.voice_assistant.async_pipeline_from_audio_stream", new=async_pipeline_from_audio_stream, ): - server = esphome.voice_assistant.VoiceAssistantUDPServer(hass) - server.transport = Mock() + voice_assistant_udp_server_v1.transport = Mock() - await server.run_pipeline(handle_event) + await voice_assistant_udp_server_v1.run_pipeline(handle_event) async def test_udp_server( hass: HomeAssistant, socket_enabled, unused_udp_port_factory, + voice_assistant_udp_server_v1: VoiceAssistantUDPServer, ) -> None: """Test the UDP server runs and queues incoming data.""" port_to_use = unused_udp_port_factory() - server = esphome.voice_assistant.VoiceAssistantUDPServer(hass) with patch( "homeassistant.components.esphome.voice_assistant.UDP_PORT", new=port_to_use ): - port = await server.start_server() + port = await voice_assistant_udp_server_v1.start_server() assert port == port_to_use sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - assert server.queue.qsize() == 0 + assert voice_assistant_udp_server_v1.queue.qsize() == 0 sock.sendto(b"test", ("127.0.0.1", port)) # Give the socket some time to send/receive the data async with async_timeout.timeout(1): - while server.queue.qsize() == 0: + while voice_assistant_udp_server_v1.queue.qsize() == 0: await asyncio.sleep(0.1) - assert server.queue.qsize() == 1 + assert voice_assistant_udp_server_v1.queue.qsize() == 1 - server.stop() + voice_assistant_udp_server_v1.stop() - assert server.transport.is_closing() + assert voice_assistant_udp_server_v1.transport.is_closing() async def test_udp_server_multiple( hass: HomeAssistant, socket_enabled, unused_udp_port_factory, + voice_assistant_udp_server_v1: VoiceAssistantUDPServer, ) -> None: """Test that the UDP server raises an error if started twice.""" - server = esphome.voice_assistant.VoiceAssistantUDPServer(hass) with patch( "homeassistant.components.esphome.voice_assistant.UDP_PORT", new=unused_udp_port_factory(), ): - await server.start_server() + await voice_assistant_udp_server_v1.start_server() with patch( "homeassistant.components.esphome.voice_assistant.UDP_PORT", new=unused_udp_port_factory(), ), pytest.raises(RuntimeError): pass - await server.start_server() + await voice_assistant_udp_server_v1.start_server() async def test_udp_server_after_stopped( hass: HomeAssistant, socket_enabled, unused_udp_port_factory, + voice_assistant_udp_server_v1: VoiceAssistantUDPServer, ) -> None: """Test that the UDP server raises an error if started after stopped.""" - server = esphome.voice_assistant.VoiceAssistantUDPServer(hass) - server.stop() + voice_assistant_udp_server_v1.stop() with patch( "homeassistant.components.esphome.voice_assistant.UDP_PORT", new=unused_udp_port_factory(), ), pytest.raises(RuntimeError): - await server.start_server() + await voice_assistant_udp_server_v1.start_server() From 4e7f39e3d08acf4bfde70c2caf20926e5cd70bec Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Thu, 27 Apr 2023 00:52:17 +0200 Subject: [PATCH 005/822] Fix reconfigure by SSDP or Zeroconf discovery in Synology DSM (#92088) --- .../components/synology_dsm/config_flow.py | 21 +++++-------------- .../synology_dsm/test_config_flow.py | 20 +++++++++++++----- 2 files changed, 20 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/synology_dsm/config_flow.py b/homeassistant/components/synology_dsm/config_flow.py index 9342849b2fe1..36eb37b7882c 100644 --- a/homeassistant/components/synology_dsm/config_flow.py +++ b/homeassistant/components/synology_dsm/config_flow.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections.abc import Mapping -from ipaddress import ip_address +from ipaddress import ip_address as ip import logging from typing import Any, cast from urllib.parse import urlparse @@ -38,6 +38,7 @@ from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import DiscoveryInfoType +from homeassistant.util.network import is_ip_address as is_ip from .const import ( CONF_DEVICE_TOKEN, @@ -99,14 +100,6 @@ def _ordered_shared_schema( } -def _is_valid_ip(text: str) -> bool: - try: - ip_address(text) - except ValueError: - return False - return True - - def format_synology_mac(mac: str) -> str: """Format a mac address to the format used by Synology DSM.""" return mac.replace(":", "").replace("-", "").upper() @@ -284,16 +277,12 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): break self._abort_if_unique_id_configured() - fqdn_with_ssl_verification = ( - existing_entry - and not _is_valid_ip(existing_entry.data[CONF_HOST]) - and existing_entry.data[CONF_VERIFY_SSL] - ) - if ( existing_entry + and is_ip(existing_entry.data[CONF_HOST]) + and is_ip(host) and existing_entry.data[CONF_HOST] != host - and not fqdn_with_ssl_verification + and ip(existing_entry.data[CONF_HOST]).version == ip(host).version ): _LOGGER.info( "Update host from '%s' to '%s' for NAS '%s' via discovery", diff --git a/tests/components/synology_dsm/test_config_flow.py b/tests/components/synology_dsm/test_config_flow.py index 3852712f70df..ef4dee7c597f 100644 --- a/tests/components/synology_dsm/test_config_flow.py +++ b/tests/components/synology_dsm/test_config_flow.py @@ -512,7 +512,7 @@ async def test_reconfig_ssdp(hass: HomeAssistant, service: MagicMock) -> None: MockConfigEntry( domain=DOMAIN, data={ - CONF_HOST: "wrong_host", + CONF_HOST: "192.168.1.3", CONF_VERIFY_SSL: VERIFY_SSL, CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD, @@ -539,14 +539,24 @@ async def test_reconfig_ssdp(hass: HomeAssistant, service: MagicMock) -> None: @pytest.mark.usefixtures("mock_setup_entry") -async def test_skip_reconfig_ssdp(hass: HomeAssistant, service: MagicMock) -> None: +@pytest.mark.parametrize( + ("current_host", "new_host"), + [ + ("some.fqdn", "192.168.1.5"), + ("192.168.1.5", "abcd:1234::"), + ("abcd:1234::", "192.168.1.5"), + ], +) +async def test_skip_reconfig_ssdp( + hass: HomeAssistant, current_host: str, new_host: str, service: MagicMock +) -> None: """Test re-configuration of already existing entry by ssdp.""" MockConfigEntry( domain=DOMAIN, data={ - CONF_HOST: "wrong_host", - CONF_VERIFY_SSL: True, + CONF_HOST: current_host, + CONF_VERIFY_SSL: VERIFY_SSL, CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD, CONF_MAC: MACS, @@ -560,7 +570,7 @@ async def test_skip_reconfig_ssdp(hass: HomeAssistant, service: MagicMock) -> No data=ssdp.SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", - ssdp_location="http://192.168.1.5:5000", + ssdp_location=f"http://{new_host}:5000", upnp={ ssdp.ATTR_UPNP_FRIENDLY_NAME: "mydsm", ssdp.ATTR_UPNP_SERIAL: "001132XXXX59", # Existing in MACS[0], but SSDP does not have `-` From 9f885a68ff7495fadc891122272223f166d2e3c7 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 27 Apr 2023 01:39:32 +0200 Subject: [PATCH 006/822] Update YARL to 1.9.2 (#92086) --- 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 52429425462a..3a9465869c2b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -51,7 +51,7 @@ ulid-transform==0.7.0 voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtcvad==2.0.10 -yarl==1.9.1 +yarl==1.9.2 zeroconf==0.58.2 # Constrain pycryptodome to avoid vulnerability diff --git a/pyproject.toml b/pyproject.toml index ab938e56b4c1..785332bf5ad3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,7 +53,7 @@ dependencies = [ "ulid-transform==0.7.0", "voluptuous==0.13.1", "voluptuous-serialize==2.6.0", - "yarl==1.9.1", + "yarl==1.9.2", ] [project.urls] diff --git a/requirements.txt b/requirements.txt index 1196e9b6f6fb..3f24d2897ce8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,4 +27,4 @@ typing-extensions>=4.5.0,<5.0 ulid-transform==0.7.0 voluptuous==0.13.1 voluptuous-serialize==2.6.0 -yarl==1.9.1 +yarl==1.9.2 From 13fc22aa3ca191e51459a5a5b19a5e51c1a66d32 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 27 Apr 2023 01:39:49 +0200 Subject: [PATCH 007/822] Update syrupy to 4.0.2 (#92089) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 82e861fc9e97..11cd25fa8c26 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -31,7 +31,7 @@ pytest-xdist==3.2.1 pytest==7.3.1 requests_mock==1.10.0 respx==0.20.1 -syrupy==4.0.0 +syrupy==4.0.2 tomli==2.0.1;python_version<"3.11" tqdm==4.64.0 types-atomicwrites==1.4.1 From ddc2807361d7b0fe0b89063350f27ee8b16e8c68 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 26 Apr 2023 22:13:21 -0400 Subject: [PATCH 008/822] Fix capitalization names Assist entities (#92098) * Fix capitalization names Assist entities * Adjust names to be 'in progress' * Update tests/components/esphome/test_binary_sensor.py Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --------- Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- .../components/assist_pipeline/strings.json | 7 ++++++- homeassistant/components/esphome/binary_sensor.py | 8 ++++---- homeassistant/components/esphome/strings.json | 4 ++-- homeassistant/components/voip/binary_sensor.py | 12 ++++++------ homeassistant/components/voip/strings.json | 6 +++--- tests/components/esphome/test_binary_sensor.py | 10 +++++----- tests/components/voip/test_binary_sensor.py | 10 +++++----- 7 files changed, 31 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/assist_pipeline/strings.json b/homeassistant/components/assist_pipeline/strings.json index 8ee0ad286b90..d85eb1aaed98 100644 --- a/homeassistant/components/assist_pipeline/strings.json +++ b/homeassistant/components/assist_pipeline/strings.json @@ -1,8 +1,13 @@ { "entity": { + "binary_sensor": { + "assist_in_progress": { + "name": "Assist in progress" + } + }, "select": { "pipeline": { - "name": "Assist Pipeline", + "name": "Assist pipeline", "state": { "preferred": "Preferred" } diff --git a/homeassistant/components/esphome/binary_sensor.py b/homeassistant/components/esphome/binary_sensor.py index ccfa4306880e..77ec780acb35 100644 --- a/homeassistant/components/esphome/binary_sensor.py +++ b/homeassistant/components/esphome/binary_sensor.py @@ -34,7 +34,7 @@ async def async_setup_entry( entry_data = DomainData.get(hass).get_entry_data(entry) assert entry_data.device_info is not None if entry_data.device_info.voice_assistant_version: - async_add_entities([EsphomeCallActiveBinarySensor(entry_data)]) + async_add_entities([EsphomeAssistInProgressBinarySensor(entry_data)]) class EsphomeBinarySensor( @@ -68,12 +68,12 @@ class EsphomeBinarySensor( return super().available -class EsphomeCallActiveBinarySensor(EsphomeAssistEntity, BinarySensorEntity): +class EsphomeAssistInProgressBinarySensor(EsphomeAssistEntity, BinarySensorEntity): """A binary sensor implementation for ESPHome for use with assist_pipeline.""" entity_description = BinarySensorEntityDescription( - key="call_active", - translation_key="call_active", + key="assist_in_progress", + translation_key="assist_in_progress", ) @property diff --git a/homeassistant/components/esphome/strings.json b/homeassistant/components/esphome/strings.json index 7171339ac0ae..81350c2c6537 100644 --- a/homeassistant/components/esphome/strings.json +++ b/homeassistant/components/esphome/strings.json @@ -48,8 +48,8 @@ }, "entity": { "binary_sensor": { - "call_active": { - "name": "Call Active" + "assist_in_progress": { + "name": "[%key:component::assist_pipeline::entity::binary_sensor::assist_in_progress::name%]" } }, "select": { diff --git a/homeassistant/components/voip/binary_sensor.py b/homeassistant/components/voip/binary_sensor.py index 70ecb8709842..8eeefbd5d94c 100644 --- a/homeassistant/components/voip/binary_sensor.py +++ b/homeassistant/components/voip/binary_sensor.py @@ -31,19 +31,19 @@ async def async_setup_entry( @callback def async_add_device(device: VoIPDevice) -> None: """Add device.""" - async_add_entities([VoIPCallActive(device)]) + async_add_entities([VoIPCallInProgress(device)]) domain_data.devices.async_add_new_device_listener(async_add_device) - async_add_entities([VoIPCallActive(device) for device in domain_data.devices]) + async_add_entities([VoIPCallInProgress(device) for device in domain_data.devices]) -class VoIPCallActive(VoIPEntity, BinarySensorEntity): - """Entity to represent voip is allowed.""" +class VoIPCallInProgress(VoIPEntity, BinarySensorEntity): + """Entity to represent voip call is in progress.""" entity_description = BinarySensorEntityDescription( - key="call_active", - translation_key="call_active", + key="call_in_progress", + translation_key="call_in_progress", ) _attr_is_on = False diff --git a/homeassistant/components/voip/strings.json b/homeassistant/components/voip/strings.json index 6eb9d36df736..83931d42c578 100644 --- a/homeassistant/components/voip/strings.json +++ b/homeassistant/components/voip/strings.json @@ -11,13 +11,13 @@ }, "entity": { "binary_sensor": { - "call_active": { - "name": "Call Active" + "call_in_progress": { + "name": "Call in progress" } }, "switch": { "allow_call": { - "name": "Allow Calls" + "name": "Allow calls" } }, "select": { diff --git a/tests/components/esphome/test_binary_sensor.py b/tests/components/esphome/test_binary_sensor.py index 90cf99747f0e..3f780f3003dd 100644 --- a/tests/components/esphome/test_binary_sensor.py +++ b/tests/components/esphome/test_binary_sensor.py @@ -5,24 +5,24 @@ from homeassistant.components.esphome import DomainData from homeassistant.core import HomeAssistant -async def test_call_active( +async def test_assist_in_progress( hass: HomeAssistant, mock_voice_assistant_v1_entry, ) -> None: - """Test call active binary sensor.""" + """Test assist in progress binary sensor.""" entry_data = DomainData.get(hass).get_entry_data(mock_voice_assistant_v1_entry) - state = hass.states.get("binary_sensor.test_call_active") + state = hass.states.get("binary_sensor.test_assist_in_progress") assert state is not None assert state.state == "off" entry_data.async_set_assist_pipeline_state(True) - state = hass.states.get("binary_sensor.test_call_active") + state = hass.states.get("binary_sensor.test_assist_in_progress") assert state.state == "on" entry_data.async_set_assist_pipeline_state(False) - state = hass.states.get("binary_sensor.test_call_active") + state = hass.states.get("binary_sensor.test_assist_in_progress") assert state.state == "off" diff --git a/tests/components/voip/test_binary_sensor.py b/tests/components/voip/test_binary_sensor.py index 67a0bef0f625..794d307ee01c 100644 --- a/tests/components/voip/test_binary_sensor.py +++ b/tests/components/voip/test_binary_sensor.py @@ -4,22 +4,22 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -async def test_allow_call( +async def test_call_in_progress( hass: HomeAssistant, config_entry: ConfigEntry, voip_device: VoIPDevice, ) -> None: - """Test allow call.""" - state = hass.states.get("binary_sensor.192_168_1_210_call_active") + """Test call in progress.""" + state = hass.states.get("binary_sensor.192_168_1_210_call_in_progress") assert state is not None assert state.state == "off" voip_device.set_is_active(True) - state = hass.states.get("binary_sensor.192_168_1_210_call_active") + state = hass.states.get("binary_sensor.192_168_1_210_call_in_progress") assert state.state == "on" voip_device.set_is_active(False) - state = hass.states.get("binary_sensor.192_168_1_210_call_active") + state = hass.states.get("binary_sensor.192_168_1_210_call_in_progress") assert state.state == "off" From 32ed45084a310107a1d2f49b8fa98a9cdf752690 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 27 Apr 2023 14:24:29 +1200 Subject: [PATCH 009/822] ESPHome voice assistant: Version 2 - Stream raw tts audio back to device for playback (#92052) * Send raw audio back * Update tests * More tests * Fix docstrings and remove unused patches * More tests * MORE * Only set raw for v2 --- homeassistant/components/esphome/__init__.py | 29 ++- .../components/esphome/voice_assistant.py | 163 ++++++++++---- tests/components/esphome/conftest.py | 35 +++ .../esphome/test_voice_assistant.py | 209 ++++++++++++++++-- 4 files changed, 367 insertions(+), 69 deletions(-) diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index 6ce5f656d6e8..a68dd562af14 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -288,39 +288,46 @@ async def async_setup_entry( # noqa: C901 voice_assistant_udp_server: VoiceAssistantUDPServer | None = None - def handle_pipeline_event( + def _handle_pipeline_event( event_type: VoiceAssistantEventType, data: dict[str, str] | None ) -> None: - """Handle a voice assistant pipeline event.""" cli.send_voice_assistant_event(event_type, data) - async def handle_pipeline_start() -> int | None: + def _handle_pipeline_finished() -> None: + nonlocal voice_assistant_udp_server + + entry_data.async_set_assist_pipeline_state(False) + + if voice_assistant_udp_server is not None: + voice_assistant_udp_server.close() + voice_assistant_udp_server = None + + async def _handle_pipeline_start() -> int | None: """Start a voice assistant pipeline.""" nonlocal voice_assistant_udp_server if voice_assistant_udp_server is not None: return None - voice_assistant_udp_server = VoiceAssistantUDPServer(hass, entry_data) + voice_assistant_udp_server = VoiceAssistantUDPServer( + hass, entry_data, _handle_pipeline_event, _handle_pipeline_finished + ) port = await voice_assistant_udp_server.start_server() hass.async_create_background_task( - voice_assistant_udp_server.run_pipeline(handle_pipeline_event), + voice_assistant_udp_server.run_pipeline(), "esphome.voice_assistant_udp_server.run_pipeline", ) entry_data.async_set_assist_pipeline_state(True) return port - async def handle_pipeline_stop() -> None: + async def _handle_pipeline_stop() -> None: """Stop a voice assistant pipeline.""" nonlocal voice_assistant_udp_server - entry_data.async_set_assist_pipeline_state(False) - if voice_assistant_udp_server is not None: voice_assistant_udp_server.stop() - voice_assistant_udp_server = None async def on_connect() -> None: """Subscribe to states and list entities on successful API login.""" @@ -369,8 +376,8 @@ async def async_setup_entry( # noqa: C901 if device_info.voice_assistant_version: entry_data.disconnect_callbacks.append( await cli.subscribe_voice_assistant( - handle_pipeline_start, - handle_pipeline_stop, + _handle_pipeline_start, + _handle_pipeline_stop, ) ) diff --git a/homeassistant/components/esphome/voice_assistant.py b/homeassistant/components/esphome/voice_assistant.py index b6c76e00f4c5..aaa2dc80a78c 100644 --- a/homeassistant/components/esphome/voice_assistant.py +++ b/homeassistant/components/esphome/voice_assistant.py @@ -8,8 +8,9 @@ import socket from typing import cast from aioesphomeapi import VoiceAssistantEventType +import async_timeout -from homeassistant.components import stt +from homeassistant.components import stt, tts from homeassistant.components.assist_pipeline import ( PipelineEvent, PipelineEventType, @@ -26,6 +27,7 @@ from .enum_mapper import EsphomeEnumMapper _LOGGER = logging.getLogger(__name__) UDP_PORT = 0 # Set to 0 to let the OS pick a free random port +UDP_MAX_PACKET_SIZE = 1024 _VOICE_ASSISTANT_EVENT_TYPES: EsphomeEnumMapper[ VoiceAssistantEventType, PipelineEventType @@ -50,11 +52,14 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): started = False queue: asyncio.Queue[bytes] | None = None transport: asyncio.DatagramTransport | None = None + remote_addr: tuple[str, int] | None = None def __init__( self, hass: HomeAssistant, entry_data: RuntimeEntryData, + handle_event: Callable[[VoiceAssistantEventType, dict[str, str] | None], None], + handle_finished: Callable[[], None], ) -> None: """Initialize UDP receiver.""" self.context = Context() @@ -64,6 +69,9 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): self.device_info = entry_data.device_info self.queue = asyncio.Queue() + self.handle_event = handle_event + self.handle_finished = handle_finished + self._tts_done = asyncio.Event() async def start_server(self) -> int: """Start accepting connections.""" @@ -97,6 +105,10 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): @callback def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None: """Handle incoming UDP packet.""" + if not self.started: + return + if self.remote_addr is None: + self.remote_addr = addr if self.queue is not None: self.queue.put_nowait(data) @@ -106,12 +118,18 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): (Other than BlockingIOError or InterruptedError.) """ _LOGGER.error("ESPHome Voice Assistant UDP server error received: %s", exc) + self.handle_finished() @callback def stop(self) -> None: """Stop the receiver.""" if self.queue is not None: self.queue.put_nowait(b"") + self.started = False + + def close(self) -> None: + """Close the receiver.""" + if self.queue is not None: self.queue = None if self.transport is not None: self.transport.close() @@ -124,57 +142,112 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): while data := await self.queue.get(): yield data + def _event_callback(self, event: PipelineEvent) -> None: + """Handle pipeline events.""" + + try: + event_type = _VOICE_ASSISTANT_EVENT_TYPES.from_hass(event.type) + except KeyError: + _LOGGER.warning("Received unknown pipeline event type: %s", event.type) + return + + data_to_send = None + if event_type == VoiceAssistantEventType.VOICE_ASSISTANT_STT_END: + assert event.data is not None + data_to_send = {"text": event.data["stt_output"]["text"]} + elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_START: + assert event.data is not None + data_to_send = {"text": event.data["tts_input"]} + elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END: + assert event.data is not None + path = event.data["tts_output"]["url"] + url = async_process_play_media_url(self.hass, path) + data_to_send = {"url": url} + + if self.device_info.voice_assistant_version >= 2: + media_id = event.data["tts_output"]["media_id"] + self.hass.async_create_background_task( + self._send_tts(media_id), "esphome_voice_assistant_tts" + ) + else: + self._tts_done.set() + elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_ERROR: + assert event.data is not None + data_to_send = { + "code": event.data["code"], + "message": event.data["message"], + } + self.handle_finished() + + self.handle_event(event_type, data_to_send) + async def run_pipeline( self, - handle_event: Callable[[VoiceAssistantEventType, dict[str, str] | None], None], + pipeline_timeout: float = 30.0, ) -> None: """Run the Voice Assistant pipeline.""" + try: + tts_audio_output = ( + "raw" if self.device_info.voice_assistant_version >= 2 else "mp3" + ) + async with async_timeout.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 + ), + tts_audio_output=tts_audio_output, + ) - @callback - def handle_pipeline_event(event: PipelineEvent) -> None: - """Handle pipeline events.""" + # Block until TTS is done sending + await self._tts_done.wait() - try: - event_type = _VOICE_ASSISTANT_EVENT_TYPES.from_hass(event.type) - except KeyError: - _LOGGER.warning("Received unknown pipeline event type: %s", event.type) + _LOGGER.debug("Pipeline finished") + except asyncio.TimeoutError: + _LOGGER.warning("Pipeline timeout") + finally: + self.handle_finished() + + async def _send_tts(self, media_id: str) -> None: + """Send TTS audio to device via UDP.""" + try: + if self.transport is None: return - data_to_send = None - if event_type == VoiceAssistantEventType.VOICE_ASSISTANT_STT_END: - assert event.data is not None - data_to_send = {"text": event.data["stt_output"]["text"]} - elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_START: - assert event.data is not None - data_to_send = {"text": event.data["tts_input"]} - elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END: - assert event.data is not None - path = event.data["tts_output"]["url"] - url = async_process_play_media_url(self.hass, path) - data_to_send = {"url": url} - elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_ERROR: - assert event.data is not None - data_to_send = { - "code": event.data["code"], - "message": event.data["message"], - } + _extension, audio_bytes = await tts.async_get_media_source_audio( + self.hass, + media_id, + ) - handle_event(event_type, data_to_send) + _LOGGER.debug("Sending %d bytes of audio", len(audio_bytes)) - await async_pipeline_from_audio_stream( - self.hass, - context=self.context, - event_callback=handle_pipeline_event, - 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 - ), - ) + bytes_per_sample = stt.AudioBitRates.BITRATE_16 // 8 + sample_offset = 0 + samples_left = len(audio_bytes) // bytes_per_sample + + while samples_left > 0: + bytes_offset = sample_offset * bytes_per_sample + chunk: bytes = audio_bytes[bytes_offset : bytes_offset + 1024] + samples_in_chunk = len(chunk) // bytes_per_sample + samples_left -= samples_in_chunk + + self.transport.sendto(chunk, self.remote_addr) + await asyncio.sleep( + samples_in_chunk / stt.AudioSampleRates.SAMPLERATE_16000 * 0.99 + ) + + sample_offset += samples_in_chunk + + finally: + self._tts_done.set() diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index f5362b1fb3d0..a70686acbf6e 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -157,3 +157,38 @@ async def mock_voice_assistant_v1_entry( await hass.async_block_till_done() return entry + + +@pytest.fixture +async def mock_voice_assistant_v2_entry( + hass: HomeAssistant, + mock_client, +) -> MockConfigEntry: + """Set up an ESPHome entry with voice assistant.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "test.local", + CONF_PORT: 6053, + CONF_PASSWORD: "", + }, + ) + entry.add_to_hass(hass) + + device_info = DeviceInfo( + name="test", + friendly_name="Test", + voice_assistant_version=2, + mac_address="11:22:33:44:55:aa", + esphome_version="1.0.0", + ) + + mock_client.device_info = AsyncMock(return_value=device_info) + mock_client.subscribe_voice_assistant = AsyncMock(return_value=Mock()) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + await hass.async_block_till_done() + await hass.async_block_till_done() + + return entry diff --git a/tests/components/esphome/test_voice_assistant.py b/tests/components/esphome/test_voice_assistant.py index e1fe41829c2b..fed83f8ab101 100644 --- a/tests/components/esphome/test_voice_assistant.py +++ b/tests/components/esphome/test_voice_assistant.py @@ -4,10 +4,12 @@ import asyncio import socket from unittest.mock import Mock, patch +from aioesphomeapi import VoiceAssistantEventType import async_timeout import pytest -from homeassistant.components import assist_pipeline, esphome +from homeassistant.components import esphome +from homeassistant.components.assist_pipeline import PipelineEvent, PipelineEventType from homeassistant.components.esphome import DomainData from homeassistant.components.esphome.voice_assistant import VoiceAssistantUDPServer from homeassistant.core import HomeAssistant @@ -15,6 +17,7 @@ from homeassistant.core import HomeAssistant _TEST_INPUT_TEXT = "This is an input test" _TEST_OUTPUT_TEXT = "This is an output test" _TEST_OUTPUT_URL = "output.mp3" +_TEST_MEDIA_ID = "12345" @pytest.fixture @@ -24,11 +27,40 @@ def voice_assistant_udp_server_v1( ) -> VoiceAssistantUDPServer: """Return the UDP server.""" entry_data = DomainData.get(hass).get_entry_data(mock_voice_assistant_v1_entry) - return VoiceAssistantUDPServer(hass, entry_data) + + server: VoiceAssistantUDPServer = None + + def handle_finished(): + nonlocal server + assert server is not None + server.close() + + server = VoiceAssistantUDPServer(hass, entry_data, Mock(), handle_finished) + return server + + +@pytest.fixture +def voice_assistant_udp_server_v2( + hass: HomeAssistant, + mock_voice_assistant_v2_entry, +) -> VoiceAssistantUDPServer: + """Return the UDP server.""" + entry_data = DomainData.get(hass).get_entry_data(mock_voice_assistant_v2_entry) + + server: VoiceAssistantUDPServer = None + + def handle_finished(): + nonlocal server + assert server is not None + server.close() + + server = VoiceAssistantUDPServer(hass, entry_data, Mock(), handle_finished) + return server async def test_pipeline_events( - hass: HomeAssistant, voice_assistant_udp_server_v1: VoiceAssistantUDPServer + hass: HomeAssistant, + voice_assistant_udp_server_v1: VoiceAssistantUDPServer, ) -> None: """Test that the pipeline function is called.""" @@ -37,29 +69,29 @@ async def test_pipeline_events( # Fake events event_callback( - assist_pipeline.PipelineEvent( - type=assist_pipeline.PipelineEventType.STT_START, + PipelineEvent( + type=PipelineEventType.STT_START, data={}, ) ) event_callback( - assist_pipeline.PipelineEvent( - type=assist_pipeline.PipelineEventType.STT_END, + PipelineEvent( + type=PipelineEventType.STT_END, data={"stt_output": {"text": _TEST_INPUT_TEXT}}, ) ) event_callback( - assist_pipeline.PipelineEvent( - type=assist_pipeline.PipelineEventType.TTS_START, + PipelineEvent( + type=PipelineEventType.TTS_START, data={"tts_input": _TEST_OUTPUT_TEXT}, ) ) event_callback( - assist_pipeline.PipelineEvent( - type=assist_pipeline.PipelineEventType.TTS_END, + PipelineEvent( + type=PipelineEventType.TTS_END, data={"tts_output": {"url": _TEST_OUTPUT_URL}}, ) ) @@ -77,13 +109,15 @@ async def test_pipeline_events( assert data is not None assert data["url"] == _TEST_OUTPUT_URL + voice_assistant_udp_server_v1.handle_event = handle_event + with patch( "homeassistant.components.esphome.voice_assistant.async_pipeline_from_audio_stream", new=async_pipeline_from_audio_stream, ): voice_assistant_udp_server_v1.transport = Mock() - await voice_assistant_udp_server_v1.run_pipeline(handle_event) + await voice_assistant_udp_server_v1.run_pipeline() async def test_udp_server( @@ -114,10 +148,61 @@ async def test_udp_server( assert voice_assistant_udp_server_v1.queue.qsize() == 1 voice_assistant_udp_server_v1.stop() + voice_assistant_udp_server_v1.close() assert voice_assistant_udp_server_v1.transport.is_closing() +async def test_udp_server_queue( + hass: HomeAssistant, + voice_assistant_udp_server_v1: VoiceAssistantUDPServer, +) -> None: + """Test the UDP server queues incoming data.""" + + voice_assistant_udp_server_v1.started = True + + assert voice_assistant_udp_server_v1.queue.qsize() == 0 + + voice_assistant_udp_server_v1.datagram_received(bytes(1024), ("localhost", 0)) + assert voice_assistant_udp_server_v1.queue.qsize() == 1 + + voice_assistant_udp_server_v1.datagram_received(bytes(1024), ("localhost", 0)) + assert voice_assistant_udp_server_v1.queue.qsize() == 2 + + async for data in voice_assistant_udp_server_v1._iterate_packets(): + assert data == bytes(1024) + break + assert voice_assistant_udp_server_v1.queue.qsize() == 1 # One message removed + + voice_assistant_udp_server_v1.stop() + assert ( + voice_assistant_udp_server_v1.queue.qsize() == 2 + ) # An empty message added by stop + + voice_assistant_udp_server_v1.datagram_received(bytes(1024), ("localhost", 0)) + assert ( + voice_assistant_udp_server_v1.queue.qsize() == 2 + ) # No new messages added after stop + + voice_assistant_udp_server_v1.close() + + with pytest.raises(RuntimeError): + async for data in voice_assistant_udp_server_v1._iterate_packets(): + assert data == bytes(1024) + + +async def test_error_calls_handle_finished( + hass: HomeAssistant, + voice_assistant_udp_server_v1: VoiceAssistantUDPServer, +) -> None: + """Test that the handle_finished callback is called when an error occurs.""" + voice_assistant_udp_server_v1.handle_finished = Mock() + + voice_assistant_udp_server_v1.error_received(Exception()) + + voice_assistant_udp_server_v1.handle_finished.assert_called() + + async def test_udp_server_multiple( hass: HomeAssistant, socket_enabled, @@ -146,9 +231,107 @@ async def test_udp_server_after_stopped( voice_assistant_udp_server_v1: VoiceAssistantUDPServer, ) -> None: """Test that the UDP server raises an error if started after stopped.""" - voice_assistant_udp_server_v1.stop() + voice_assistant_udp_server_v1.close() with patch( "homeassistant.components.esphome.voice_assistant.UDP_PORT", new=unused_udp_port_factory(), ), pytest.raises(RuntimeError): await voice_assistant_udp_server_v1.start_server() + + +async def test_unknown_event_type( + hass: HomeAssistant, + voice_assistant_udp_server_v1: VoiceAssistantUDPServer, +) -> None: + """Test the UDP server does not call handle_event for unknown events.""" + voice_assistant_udp_server_v1._event_callback( + PipelineEvent( + type="unknown-event", + data={}, + ) + ) + + assert not voice_assistant_udp_server_v1.handle_event.called + + +async def test_error_event_type( + hass: HomeAssistant, + voice_assistant_udp_server_v1: VoiceAssistantUDPServer, +) -> None: + """Test the UDP server calls event handler with error.""" + voice_assistant_udp_server_v1._event_callback( + PipelineEvent( + type=PipelineEventType.ERROR, + data={"code": "code", "message": "message"}, + ) + ) + + assert voice_assistant_udp_server_v1.handle_event.called_with( + VoiceAssistantEventType.VOICE_ASSISTANT_ERROR, + {"code": "code", "message": "message"}, + ) + + +async def test_send_tts_not_called( + hass: HomeAssistant, + voice_assistant_udp_server_v1: VoiceAssistantUDPServer, +) -> None: + """Test the UDP server with a v1 device does not call _send_tts.""" + with patch( + "homeassistant.components.esphome.voice_assistant.VoiceAssistantUDPServer._send_tts" + ) as mock_send_tts: + voice_assistant_udp_server_v1._event_callback( + PipelineEvent( + type=PipelineEventType.TTS_END, + data={ + "tts_output": {"media_id": _TEST_MEDIA_ID, "url": _TEST_OUTPUT_URL} + }, + ) + ) + + mock_send_tts.assert_not_called() + + +async def test_send_tts_called( + hass: HomeAssistant, + voice_assistant_udp_server_v2: VoiceAssistantUDPServer, +) -> None: + """Test the UDP server with a v2 device calls _send_tts.""" + with patch( + "homeassistant.components.esphome.voice_assistant.VoiceAssistantUDPServer._send_tts" + ) as mock_send_tts: + voice_assistant_udp_server_v2._event_callback( + PipelineEvent( + type=PipelineEventType.TTS_END, + data={ + "tts_output": {"media_id": _TEST_MEDIA_ID, "url": _TEST_OUTPUT_URL} + }, + ) + ) + + mock_send_tts.assert_called_with(_TEST_MEDIA_ID) + + +async def test_send_tts( + hass: HomeAssistant, + voice_assistant_udp_server_v2: VoiceAssistantUDPServer, +) -> None: + """Test the UDP server calls sendto to transmit audio data to device.""" + with patch( + "homeassistant.components.esphome.voice_assistant.tts.async_get_media_source_audio", + return_value=("raw", bytes(1024)), + ): + voice_assistant_udp_server_v2.transport = Mock(spec=asyncio.DatagramTransport) + + voice_assistant_udp_server_v2._event_callback( + PipelineEvent( + type=PipelineEventType.TTS_END, + data={ + "tts_output": {"media_id": _TEST_MEDIA_ID, "url": _TEST_OUTPUT_URL} + }, + ) + ) + + await voice_assistant_udp_server_v2._tts_done.wait() + + voice_assistant_udp_server_v2.transport.sendto.assert_called() From 7c696754edd4d10c7ca51d14a1f0e9fa9e09bc3e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 26 Apr 2023 22:40:17 -0400 Subject: [PATCH 010/822] Use pipeline ID in event (#92100) * Use pipeline ID in event * Fix tests --- .../components/assist_pipeline/pipeline.py | 2 +- tests/components/assist_pipeline/__init__.py | 1 - .../assist_pipeline/snapshots/test_init.ambr | 6 +-- .../snapshots/test_websocket.ambr | 14 +++---- tests/components/assist_pipeline/test_init.py | 38 +++++++++---------- .../assist_pipeline/test_websocket.py | 17 ++++++--- 6 files changed, 40 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 87fd9be0c421..d347e433f463 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -370,7 +370,7 @@ class PipelineRun: def start(self) -> None: """Emit run start event.""" data = { - "pipeline": self.pipeline.name, + "pipeline": self.pipeline.id, "language": self.language, } if self.runner_data is not None: diff --git a/tests/components/assist_pipeline/__init__.py b/tests/components/assist_pipeline/__init__.py index 7400fe32d70a..40aa48fbc54b 100644 --- a/tests/components/assist_pipeline/__init__.py +++ b/tests/components/assist_pipeline/__init__.py @@ -1,5 +1,4 @@ """Tests for the Voice Assistant integration.""" - MANY_LANGUAGES = [ "ar", "bg", diff --git a/tests/components/assist_pipeline/snapshots/test_init.ambr b/tests/components/assist_pipeline/snapshots/test_init.ambr index 5191e948c382..619c59606ed3 100644 --- a/tests/components/assist_pipeline/snapshots/test_init.ambr +++ b/tests/components/assist_pipeline/snapshots/test_init.ambr @@ -4,7 +4,7 @@ dict({ 'data': dict({ 'language': 'en', - 'pipeline': 'Home Assistant', + 'pipeline': , }), 'type': , }), @@ -91,7 +91,7 @@ dict({ 'data': dict({ 'language': 'en', - 'pipeline': 'test_name', + 'pipeline': , }), 'type': , }), @@ -178,7 +178,7 @@ dict({ 'data': dict({ 'language': 'en', - 'pipeline': 'test_name', + 'pipeline': , }), 'type': , }), diff --git a/tests/components/assist_pipeline/snapshots/test_websocket.ambr b/tests/components/assist_pipeline/snapshots/test_websocket.ambr index f5a0a6dad92e..a2e5ac72b075 100644 --- a/tests/components/assist_pipeline/snapshots/test_websocket.ambr +++ b/tests/components/assist_pipeline/snapshots/test_websocket.ambr @@ -2,7 +2,7 @@ # name: test_audio_pipeline dict({ 'language': 'en', - 'pipeline': 'Home Assistant', + 'pipeline': , 'runner_data': dict({ 'stt_binary_handler_id': 1, 'timeout': 30, @@ -78,7 +78,7 @@ # name: test_audio_pipeline_debug dict({ 'language': 'en', - 'pipeline': 'Home Assistant', + 'pipeline': , 'runner_data': dict({ 'stt_binary_handler_id': 1, 'timeout': 30, @@ -154,7 +154,7 @@ # name: test_intent_failed dict({ 'language': 'en', - 'pipeline': 'Home Assistant', + 'pipeline': , 'runner_data': dict({ 'stt_binary_handler_id': None, 'timeout': 30, @@ -171,7 +171,7 @@ # name: test_intent_timeout dict({ 'language': 'en', - 'pipeline': 'Home Assistant', + 'pipeline': , 'runner_data': dict({ 'stt_binary_handler_id': None, 'timeout': 0.1, @@ -217,7 +217,7 @@ # name: test_stt_stream_failed dict({ 'language': 'en', - 'pipeline': 'Home Assistant', + 'pipeline': , 'runner_data': dict({ 'stt_binary_handler_id': 1, 'timeout': 30, @@ -240,7 +240,7 @@ # name: test_text_only_pipeline dict({ 'language': 'en', - 'pipeline': 'Home Assistant', + 'pipeline': , 'runner_data': dict({ 'stt_binary_handler_id': None, 'timeout': 30, @@ -285,7 +285,7 @@ # name: test_tts_failed dict({ 'language': 'en', - 'pipeline': 'Home Assistant', + 'pipeline': , 'runner_data': dict({ 'stt_binary_handler_id': None, 'timeout': 30, diff --git a/tests/components/assist_pipeline/test_init.py b/tests/components/assist_pipeline/test_init.py index 6dd04c37e393..6fb6bf61d96e 100644 --- a/tests/components/assist_pipeline/test_init.py +++ b/tests/components/assist_pipeline/test_init.py @@ -1,5 +1,6 @@ """Test Voice Assistant init.""" from dataclasses import asdict +from unittest.mock import ANY import pytest from syrupy.assertion import SnapshotAssertion @@ -12,6 +13,19 @@ from .conftest import MockSttProvider, MockSttProviderEntity from tests.typing import WebSocketGenerator +def process_events(events: list[assist_pipeline.PipelineEvent]) -> list[dict]: + """Process events to remove dynamic values.""" + processed = [] + for event in events: + as_dict = asdict(event) + as_dict.pop("timestamp") + if as_dict["type"] == assist_pipeline.PipelineEventType.RUN_START: + as_dict["data"]["pipeline"] = ANY + processed.append(as_dict) + + return processed + + async def test_pipeline_from_audio_stream_auto( hass: HomeAssistant, mock_stt_provider: MockSttProvider, @@ -45,13 +59,7 @@ async def test_pipeline_from_audio_stream_auto( audio_data(), ) - processed = [] - for event in events: - as_dict = asdict(event) - as_dict.pop("timestamp") - processed.append(as_dict) - - assert processed == snapshot + assert process_events(events) == snapshot assert mock_stt_provider.received == [b"part1", b"part2"] @@ -111,13 +119,7 @@ async def test_pipeline_from_audio_stream_legacy( pipeline_id=pipeline_id, ) - processed = [] - for event in events: - as_dict = asdict(event) - as_dict.pop("timestamp") - processed.append(as_dict) - - assert processed == snapshot + assert process_events(events) == snapshot assert mock_stt_provider.received == [b"part1", b"part2"] @@ -177,13 +179,7 @@ async def test_pipeline_from_audio_stream_entity( pipeline_id=pipeline_id, ) - processed = [] - for event in events: - as_dict = asdict(event) - as_dict.pop("timestamp") - processed.append(as_dict) - - assert processed == snapshot + assert process_events(events) == snapshot assert mock_stt_provider_entity.received == [b"part1", b"part2"] diff --git a/tests/components/assist_pipeline/test_websocket.py b/tests/components/assist_pipeline/test_websocket.py index 827d7b851139..c71d0526fe6e 100644 --- a/tests/components/assist_pipeline/test_websocket.py +++ b/tests/components/assist_pipeline/test_websocket.py @@ -1,6 +1,6 @@ """Websocket tests for Voice Assistant integration.""" import asyncio -from unittest.mock import ANY, MagicMock, patch +from unittest.mock import ANY, patch from syrupy.assertion import SnapshotAssertion @@ -37,6 +37,7 @@ async def test_text_only_pipeline( # run start msg = await client.receive_json() assert msg["event"]["type"] == "run-start" + msg["event"]["data"]["pipeline"] = ANY assert msg["event"]["data"] == snapshot events.append(msg["event"]) @@ -101,6 +102,7 @@ async def test_audio_pipeline( # run start msg = await client.receive_json() assert msg["event"]["type"] == "run-start" + msg["event"]["data"]["pipeline"] = ANY assert msg["event"]["data"] == snapshot events.append(msg["event"]) @@ -196,6 +198,7 @@ async def test_intent_timeout( # run start msg = await client.receive_json() assert msg["event"]["type"] == "run-start" + msg["event"]["data"]["pipeline"] = ANY assert msg["event"]["data"] == snapshot events.append(msg["event"]) @@ -292,7 +295,7 @@ async def test_intent_failed( with patch( "homeassistant.components.conversation.async_converse", - new=MagicMock(return_value=RuntimeError), + side_effect=RuntimeError, ): await client.send_json_auto_id( { @@ -310,6 +313,7 @@ async def test_intent_failed( # run start msg = await client.receive_json() assert msg["event"]["type"] == "run-start" + msg["event"]["data"]["pipeline"] = ANY assert msg["event"]["data"] == snapshot events.append(msg["event"]) @@ -405,7 +409,7 @@ async def test_stt_provider_missing( """Test events from a pipeline run with a non-existent STT provider.""" with patch( "homeassistant.components.stt.async_get_provider", - new=MagicMock(return_value=None), + return_value=None, ): client = await hass_ws_client(hass) @@ -438,7 +442,7 @@ async def test_stt_stream_failed( with patch( "tests.components.assist_pipeline.conftest.MockSttProvider.async_process_audio_stream", - new=MagicMock(side_effect=RuntimeError), + side_effect=RuntimeError, ): await client.send_json_auto_id( { @@ -458,6 +462,7 @@ async def test_stt_stream_failed( # run start msg = await client.receive_json() assert msg["event"]["type"] == "run-start" + msg["event"]["data"]["pipeline"] = ANY assert msg["event"]["data"] == snapshot events.append(msg["event"]) @@ -504,7 +509,7 @@ async def test_tts_failed( with patch( "homeassistant.components.media_source.async_resolve_media", - new=MagicMock(return_value=RuntimeError), + side_effect=RuntimeError, ): await client.send_json_auto_id( { @@ -522,6 +527,7 @@ async def test_tts_failed( # run start msg = await client.receive_json() assert msg["event"]["type"] == "run-start" + msg["event"]["data"]["pipeline"] = ANY assert msg["event"]["data"] == snapshot events.append(msg["event"]) @@ -1105,6 +1111,7 @@ async def test_audio_pipeline_debug( # run start msg = await client.receive_json() assert msg["event"]["type"] == "run-start" + msg["event"]["data"]["pipeline"] = ANY assert msg["event"]["data"] == snapshot events.append(msg["event"]) From 7d2243344e1aaa05ac8986bf9c9047693d35c60f Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Thu, 27 Apr 2023 03:22:03 -0400 Subject: [PATCH 011/822] Fix vizio integration_type (#92103) --- homeassistant/components/vizio/manifest.json | 2 +- homeassistant/generated/integrations.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/vizio/manifest.json b/homeassistant/components/vizio/manifest.json index 9b63ef17a9ca..999ef2690358 100644 --- a/homeassistant/components/vizio/manifest.json +++ b/homeassistant/components/vizio/manifest.json @@ -4,7 +4,7 @@ "codeowners": ["@raman325"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/vizio", - "integration_type": "hub", + "integration_type": "device", "iot_class": "local_polling", "loggers": ["pyvizio"], "quality_scale": "platinum", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index b0c164da1ed3..d85765aec4c3 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6059,7 +6059,7 @@ }, "vizio": { "name": "VIZIO SmartCast", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, From bd72ea11fcacea494d729d02b0db5b2788012356 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 27 Apr 2023 09:43:34 +0200 Subject: [PATCH 012/822] Update sentry-sdk to 1.21.0 (#92087) --- 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 924c660da158..8db94696e717 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.20.0"] + "requirements": ["sentry-sdk==1.21.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index be8f818acf02..429317a623b0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2333,7 +2333,7 @@ sensorpro-ble==0.5.3 sensorpush-ble==1.5.5 # homeassistant.components.sentry -sentry-sdk==1.20.0 +sentry-sdk==1.21.0 # homeassistant.components.sfr_box sfrbox-api==0.0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1d3d2b3fc885..f5e836dd6d8a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1672,7 +1672,7 @@ sensorpro-ble==0.5.3 sensorpush-ble==1.5.5 # homeassistant.components.sentry -sentry-sdk==1.20.0 +sentry-sdk==1.21.0 # homeassistant.components.sfr_box sfrbox-api==0.0.6 From a164530a64a19375cb723c3677623a936e7cc062 Mon Sep 17 00:00:00 2001 From: avee87 <6134677+avee87@users.noreply.github.com> Date: Thu, 27 Apr 2023 08:45:49 +0100 Subject: [PATCH 013/822] Remove name attribute from transmission services manifest (#92083) --- .../components/transmission/services.yaml | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/homeassistant/components/transmission/services.yaml b/homeassistant/components/transmission/services.yaml index 66f4daf200f4..34a885284119 100644 --- a/homeassistant/components/transmission/services.yaml +++ b/homeassistant/components/transmission/services.yaml @@ -26,12 +26,6 @@ remove_torrent: selector: config_entry: integration: transmission - name: - name: Name - description: Instance name as entered during entry config - example: Transmission - selector: - text: id: name: ID description: ID of a torrent @@ -56,12 +50,6 @@ start_torrent: selector: config_entry: integration: transmission - name: - name: Name - description: Instance name as entered during entry config - example: Transmission - selector: - text: id: name: ID description: ID of a torrent @@ -79,12 +67,6 @@ stop_torrent: selector: config_entry: integration: transmission - name: - name: Name - description: Instance name as entered during entry config - example: Transmission - selector: - text: id: name: ID description: ID of a torrent From 65c9d4a4ae9c1732723ba012620926f556a8d147 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 27 Apr 2023 10:38:21 +0200 Subject: [PATCH 014/822] Avoid exposing unsupported entities to Google Assistant (#92105) * Avoid exposing unsupported entities to Google Assistant * Add Google Assistant specific support sets * Add test --- .../components/cloud/google_config.py | 78 ++++++++++++- tests/components/cloud/test_google_config.py | 103 ++++++++++++++++++ 2 files changed, 179 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/cloud/google_config.py b/homeassistant/components/cloud/google_config.py index c47b05c264c7..29b9c62ea1db 100644 --- a/homeassistant/components/cloud/google_config.py +++ b/homeassistant/components/cloud/google_config.py @@ -7,12 +7,14 @@ from typing import Any from hass_nabucasa import Cloud, cloud_api from hass_nabucasa.google_report_state import ErrorResponse +from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.google_assistant import DOMAIN as GOOGLE_DOMAIN from homeassistant.components.google_assistant.helpers import AbstractConfig from homeassistant.components.homeassistant.exposed_entities import ( async_listen_entity_updates, async_should_expose, ) +from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES from homeassistant.core import ( CoreState, @@ -22,6 +24,7 @@ from homeassistant.core import ( split_entity_id, ) from homeassistant.helpers import device_registry as dr, entity_registry as er, start +from homeassistant.helpers.entity import get_device_class from homeassistant.setup import async_setup_component from .const import ( @@ -39,6 +42,73 @@ _LOGGER = logging.getLogger(__name__) CLOUD_GOOGLE = f"{CLOUD_DOMAIN}.{GOOGLE_DOMAIN}" +SUPPORTED_DOMAINS = { + "alarm_control_panel", + "button", + "camera", + "climate", + "cover", + "fan", + "group", + "humidifier", + "input_boolean", + "input_button", + "input_select", + "light", + "lock", + "media_player", + "scene", + "script", + "select", + "switch", + "vacuum", +} + +SUPPORTED_BINARY_SENSOR_DEVICE_CLASSES = { + BinarySensorDeviceClass.DOOR, + BinarySensorDeviceClass.GARAGE_DOOR, + BinarySensorDeviceClass.LOCK, + BinarySensorDeviceClass.MOTION, + BinarySensorDeviceClass.OPENING, + BinarySensorDeviceClass.PRESENCE, + BinarySensorDeviceClass.WINDOW, +} + +SUPPORTED_SENSOR_DEVICE_CLASSES = { + SensorDeviceClass.AQI, + SensorDeviceClass.CO, + SensorDeviceClass.CO2, + SensorDeviceClass.HUMIDITY, + SensorDeviceClass.PM10, + SensorDeviceClass.PM25, + SensorDeviceClass.TEMPERATURE, + SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, +} + + +def _supported_legacy(hass: HomeAssistant, entity_id: str) -> bool: + """Return if the entity is supported. + + This is called when migrating from legacy config format to avoid exposing + all binary sensors and sensors. + """ + domain = split_entity_id(entity_id)[0] + if domain in SUPPORTED_DOMAINS: + return True + + device_class = get_device_class(hass, entity_id) + if ( + domain == "binary_sensor" + and device_class in SUPPORTED_BINARY_SENSOR_DEVICE_CLASSES + ): + return True + + if domain == "sensor" and device_class in SUPPORTED_SENSOR_DEVICE_CLASSES: + return True + + return False + + class CloudGoogleConfig(AbstractConfig): """HA Cloud Configuration for Google Assistant.""" @@ -180,9 +250,13 @@ class CloudGoogleConfig(AbstractConfig): # Backwards compat if default_expose is None: - return not auxiliary_entity + return not auxiliary_entity and _supported_legacy(self.hass, entity_id) - return not auxiliary_entity and split_entity_id(entity_id)[0] in default_expose + return ( + not auxiliary_entity + and split_entity_id(entity_id)[0] in default_expose + and _supported_legacy(self.hass, entity_id) + ) def _should_expose_entity_id(self, entity_id): """If an entity should be exposed.""" diff --git a/tests/components/cloud/test_google_config.py b/tests/components/cloud/test_google_config.py index 738b3fa7cd76..8b927f7f3aa2 100644 --- a/tests/components/cloud/test_google_config.py +++ b/tests/components/cloud/test_google_config.py @@ -611,3 +611,106 @@ async def test_google_config_migrate_expose_entity_prefs_default_none( entity_default = entity_registry.async_get(entity_default.entity_id) assert entity_default.options == {"cloud.google_assistant": {"should_expose": True}} + + +async def test_google_config_migrate_expose_entity_prefs_default( + hass: HomeAssistant, + cloud_prefs: CloudPreferences, + entity_registry: er.EntityRegistry, +) -> None: + """Test migrating Google entity config.""" + + assert await async_setup_component(hass, "homeassistant", {}) + + binary_sensor_supported = entity_registry.async_get_or_create( + "binary_sensor", + "test", + "binary_sensor_supported", + original_device_class="door", + suggested_object_id="supported", + ) + + binary_sensor_unsupported = entity_registry.async_get_or_create( + "binary_sensor", + "test", + "binary_sensor_unsupported", + original_device_class="battery", + suggested_object_id="unsupported", + ) + + light = entity_registry.async_get_or_create( + "light", + "test", + "unique", + suggested_object_id="light", + ) + + sensor_supported = entity_registry.async_get_or_create( + "sensor", + "test", + "sensor_supported", + original_device_class="temperature", + suggested_object_id="supported", + ) + + sensor_unsupported = entity_registry.async_get_or_create( + "sensor", + "test", + "sensor_unsupported", + original_device_class="battery", + suggested_object_id="unsupported", + ) + + water_heater = entity_registry.async_get_or_create( + "water_heater", + "test", + "unique", + suggested_object_id="water_heater", + ) + + await cloud_prefs.async_update( + google_enabled=True, + google_report_state=False, + google_settings_version=1, + ) + + cloud_prefs._prefs[PREF_GOOGLE_DEFAULT_EXPOSE] = [ + "binary_sensor", + "light", + "sensor", + "water_heater", + ] + conf = CloudGoogleConfig( + hass, GACTIONS_SCHEMA({}), "mock-user-id", cloud_prefs, Mock(is_logged_in=False) + ) + await conf.async_initialize() + + binary_sensor_supported = entity_registry.async_get( + binary_sensor_supported.entity_id + ) + assert binary_sensor_supported.options == { + "cloud.google_assistant": {"should_expose": True} + } + + binary_sensor_unsupported = entity_registry.async_get( + binary_sensor_unsupported.entity_id + ) + assert binary_sensor_unsupported.options == { + "cloud.google_assistant": {"should_expose": False} + } + + light = entity_registry.async_get(light.entity_id) + assert light.options == {"cloud.google_assistant": {"should_expose": True}} + + sensor_supported = entity_registry.async_get(sensor_supported.entity_id) + assert sensor_supported.options == { + "cloud.google_assistant": {"should_expose": True} + } + + sensor_unsupported = entity_registry.async_get(sensor_unsupported.entity_id) + assert sensor_unsupported.options == { + "cloud.google_assistant": {"should_expose": False} + } + + water_heater = entity_registry.async_get(water_heater.entity_id) + assert water_heater.options == {"cloud.google_assistant": {"should_expose": False}} From 21d887dd0453d0cbbf3be42f4b3650ab92fc038d Mon Sep 17 00:00:00 2001 From: Thijs W Date: Thu, 27 Apr 2023 13:14:25 +0200 Subject: [PATCH 015/822] Fix frontier_silicon not retrying setup and missing strings (#92111) Address late review comments for frontier_silicon config flow --- homeassistant/components/frontier_silicon/__init__.py | 4 ++-- homeassistant/components/frontier_silicon/strings.json | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontier_silicon/__init__.py b/homeassistant/components/frontier_silicon/__init__.py index 4a884063f83c..62f2623d05ea 100644 --- a/homeassistant/components/frontier_silicon/__init__.py +++ b/homeassistant/components/frontier_silicon/__init__.py @@ -8,7 +8,7 @@ from afsapi import AFSAPI, ConnectionError as FSConnectionError from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady +from homeassistant.exceptions import ConfigEntryNotReady from .const import CONF_PIN, CONF_WEBFSAPI_URL, DOMAIN @@ -28,7 +28,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: await afsapi.get_power() except FSConnectionError as exception: - raise PlatformNotReady from exception + raise ConfigEntryNotReady from exception hass.data.setdefault(DOMAIN, {})[entry.entry_id] = afsapi diff --git a/homeassistant/components/frontier_silicon/strings.json b/homeassistant/components/frontier_silicon/strings.json index f40abe167528..193ca7123f45 100644 --- a/homeassistant/components/frontier_silicon/strings.json +++ b/homeassistant/components/frontier_silicon/strings.json @@ -25,7 +25,10 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "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%]", + "unknown": "[%key:common::config_flow::error::unknown%]" } }, "issues": { From 7215f6320e0f41735be850edc3be9b796db5c21b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 27 Apr 2023 13:31:24 +0200 Subject: [PATCH 016/822] Avoid exposing unsupported entities to Alexa (#92107) * Avoid exposing unsupported entities to Alexa * Update homeassistant/components/cloud/alexa_config.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .../components/cloud/alexa_config.py | 74 +++++++++++++- tests/components/cloud/test_alexa_config.py | 98 +++++++++++++++++++ 2 files changed, 170 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/cloud/alexa_config.py b/homeassistant/components/cloud/alexa_config.py index 44a42c78f092..7acfbbb7f6b2 100644 --- a/homeassistant/components/cloud/alexa_config.py +++ b/homeassistant/components/cloud/alexa_config.py @@ -20,14 +20,17 @@ from homeassistant.components.alexa import ( errors as alexa_errors, state_report as alexa_state_report, ) +from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.homeassistant.exposed_entities import ( async_get_assistant_settings, async_listen_entity_updates, async_should_expose, ) +from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES from homeassistant.core import HomeAssistant, callback, split_entity_id from homeassistant.helpers import entity_registry as er, start +from homeassistant.helpers.entity import get_device_class from homeassistant.helpers.event import async_call_later from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow @@ -51,6 +54,69 @@ CLOUD_ALEXA = f"{CLOUD_DOMAIN}.{ALEXA_DOMAIN}" SYNC_DELAY = 1 +SUPPORTED_DOMAINS = { + "alarm_control_panel", + "alert", + "automation", + "button", + "camera", + "climate", + "cover", + "fan", + "group", + "humidifier", + "image_processing", + "input_boolean", + "input_button", + "input_number", + "light", + "lock", + "media_player", + "number", + "scene", + "script", + "switch", + "timer", + "vacuum", +} + +SUPPORTED_BINARY_SENSOR_DEVICE_CLASSES = { + BinarySensorDeviceClass.DOOR, + BinarySensorDeviceClass.GARAGE_DOOR, + BinarySensorDeviceClass.MOTION, + BinarySensorDeviceClass.OPENING, + BinarySensorDeviceClass.PRESENCE, + BinarySensorDeviceClass.WINDOW, +} + +SUPPORTED_SENSOR_DEVICE_CLASSES = { + SensorDeviceClass.TEMPERATURE, +} + + +def _supported_legacy(hass: HomeAssistant, entity_id: str) -> bool: + """Return if the entity is supported. + + This is called when migrating from legacy config format to avoid exposing + all binary sensors and sensors. + """ + domain = split_entity_id(entity_id)[0] + if domain in SUPPORTED_DOMAINS: + return True + + device_class = get_device_class(hass, entity_id) + if ( + domain == "binary_sensor" + and device_class in SUPPORTED_BINARY_SENSOR_DEVICE_CLASSES + ): + return True + + if domain == "sensor" and device_class in SUPPORTED_SENSOR_DEVICE_CLASSES: + return True + + return False + + class CloudAlexaConfig(alexa_config.AbstractConfig): """Alexa Configuration.""" @@ -183,9 +249,13 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): # Backwards compat if (default_expose := self._prefs.alexa_default_expose) is None: - return not auxiliary_entity + return not auxiliary_entity and _supported_legacy(self.hass, entity_id) - return not auxiliary_entity and split_entity_id(entity_id)[0] in default_expose + return ( + not auxiliary_entity + and split_entity_id(entity_id)[0] in default_expose + and _supported_legacy(self.hass, entity_id) + ) def should_expose(self, entity_id): """If an entity should be exposed.""" diff --git a/tests/components/cloud/test_alexa_config.py b/tests/components/cloud/test_alexa_config.py index 2cb363b04209..0e1f941ab641 100644 --- a/tests/components/cloud/test_alexa_config.py +++ b/tests/components/cloud/test_alexa_config.py @@ -650,3 +650,101 @@ async def test_alexa_config_migrate_expose_entity_prefs_default_none( entity_default = entity_registry.async_get(entity_default.entity_id) assert entity_default.options == {"cloud.alexa": {"should_expose": True}} + + +async def test_alexa_config_migrate_expose_entity_prefs_default( + hass: HomeAssistant, + cloud_prefs: CloudPreferences, + cloud_stub, + entity_registry: er.EntityRegistry, +) -> None: + """Test migrating Alexa entity config.""" + + assert await async_setup_component(hass, "homeassistant", {}) + + binary_sensor_supported = entity_registry.async_get_or_create( + "binary_sensor", + "test", + "binary_sensor_supported", + original_device_class="door", + suggested_object_id="supported", + ) + + binary_sensor_unsupported = entity_registry.async_get_or_create( + "binary_sensor", + "test", + "binary_sensor_unsupported", + original_device_class="battery", + suggested_object_id="unsupported", + ) + + light = entity_registry.async_get_or_create( + "light", + "test", + "unique", + suggested_object_id="light", + ) + + sensor_supported = entity_registry.async_get_or_create( + "sensor", + "test", + "sensor_supported", + original_device_class="temperature", + suggested_object_id="supported", + ) + + sensor_unsupported = entity_registry.async_get_or_create( + "sensor", + "test", + "sensor_unsupported", + original_device_class="battery", + suggested_object_id="unsupported", + ) + + water_heater = entity_registry.async_get_or_create( + "water_heater", + "test", + "unique", + suggested_object_id="water_heater", + ) + + await cloud_prefs.async_update( + alexa_enabled=True, + alexa_report_state=False, + alexa_settings_version=1, + ) + + cloud_prefs._prefs[PREF_ALEXA_DEFAULT_EXPOSE] = [ + "binary_sensor", + "light", + "sensor", + "water_heater", + ] + conf = alexa_config.CloudAlexaConfig( + hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, cloud_stub + ) + await conf.async_initialize() + + binary_sensor_supported = entity_registry.async_get( + binary_sensor_supported.entity_id + ) + assert binary_sensor_supported.options == {"cloud.alexa": {"should_expose": True}} + + binary_sensor_unsupported = entity_registry.async_get( + binary_sensor_unsupported.entity_id + ) + assert binary_sensor_unsupported.options == { + "cloud.alexa": {"should_expose": False} + } + + light = entity_registry.async_get(light.entity_id) + assert light.options == {"cloud.alexa": {"should_expose": True}} + + sensor_supported = entity_registry.async_get(sensor_supported.entity_id) + assert sensor_supported.options == {"cloud.alexa": {"should_expose": True}} + + sensor_unsupported = entity_registry.async_get(sensor_unsupported.entity_id) + assert sensor_unsupported.options == {"cloud.alexa": {"should_expose": False}} + + water_heater = entity_registry.async_get(water_heater.entity_id) + assert water_heater.options == {"cloud.alexa": {"should_expose": False}} From aac750a44faaceee4bf32197a9192d8fe5b1b121 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 27 Apr 2023 13:33:30 +0200 Subject: [PATCH 017/822] Improve TAI8570 ignore tests in onewire (#92113) Improve TAI8570 tests in onewire --- tests/components/onewire/test_sensor.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/tests/components/onewire/test_sensor.py b/tests/components/onewire/test_sensor.py index 4d6ba3ca1182..f4a993d6d960 100644 --- a/tests/components/onewire/test_sensor.py +++ b/tests/components/onewire/test_sensor.py @@ -1,6 +1,7 @@ """Tests for 1-Wire sensors.""" from collections.abc import Generator from copy import deepcopy +import logging from unittest.mock import MagicMock, _patch_dict, patch from pyownet.protocol import OwnetError @@ -8,7 +9,7 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -68,12 +69,12 @@ async def test_tai8570_sensors( owproxy: MagicMock, device_id: str, entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, ) -> None: """The DS2602 is often used without TAI8570. The sensors should be ignored. """ - mock_devices = deepcopy(MOCK_OWPROXY_DEVICES) mock_device = mock_devices[device_id] mock_device[ATTR_INJECT_READS].append(OwnetError) @@ -82,11 +83,12 @@ async def test_tai8570_sensors( with _patch_dict(MOCK_OWPROXY_DEVICES, mock_devices): setup_owproxy_mock_devices(owproxy, Platform.SENSOR, [device_id]) - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + with caplog.at_level(logging.DEBUG): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() - expected_entities = mock_device[Platform.SENSOR] - for expected_entity in expected_entities: - entity_id = expected_entity[ATTR_ENTITY_ID] - registry_entry = entity_registry.entities.get(entity_id) - assert registry_entry is None + assert entity_registry.entities.get("sensor.12_111111111111_temperature") is None + assert "unreachable sensor /12.111111111111/TAI8570/temperature" in caplog.text + + assert entity_registry.entities.get("sensor.12_111111111111_pressure") is None + assert "unreachable sensor /12.111111111111/TAI8570/pressure" in caplog.text From 7801eeb063b02d2b8e4a6a84584d5c6d31a4d531 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 27 Apr 2023 13:34:13 +0200 Subject: [PATCH 018/822] Use snapshot in onewire diagnostics tests (#92112) --- .../onewire/snapshots/test_diagnostics.ambr | 41 +++++++++++++++++++ tests/components/onewire/test_diagnostics.py | 23 +++-------- 2 files changed, 47 insertions(+), 17 deletions(-) create mode 100644 tests/components/onewire/snapshots/test_diagnostics.ambr diff --git a/tests/components/onewire/snapshots/test_diagnostics.ambr b/tests/components/onewire/snapshots/test_diagnostics.ambr new file mode 100644 index 000000000000..f51fca7e9883 --- /dev/null +++ b/tests/components/onewire/snapshots/test_diagnostics.ambr @@ -0,0 +1,41 @@ +# serializer version: 1 +# name: test_entry_diagnostics[EF.111111111113] + dict({ + 'devices': list([ + dict({ + 'device_info': dict({ + 'identifiers': list([ + list([ + 'onewire', + 'EF.111111111113', + ]), + ]), + 'manufacturer': 'Hobby Boards', + 'model': 'HB_HUB', + 'name': 'EF.111111111113', + }), + 'family': 'EF', + 'id': 'EF.111111111113', + 'path': '/EF.111111111113/', + 'type': 'HB_HUB', + }), + ]), + 'entry': dict({ + 'data': dict({ + 'host': '**REDACTED**', + 'port': 1234, + }), + 'options': dict({ + 'device_options': dict({ + '28.222222222222': dict({ + 'precision': 'temperature9', + }), + '28.222222222223': dict({ + 'precision': 'temperature5', + }), + }), + }), + 'title': 'Mock Title', + }), + }) +# --- diff --git a/tests/components/onewire/test_diagnostics.py b/tests/components/onewire/test_diagnostics.py index a812110c3f39..4e108e41959e 100644 --- a/tests/components/onewire/test_diagnostics.py +++ b/tests/components/onewire/test_diagnostics.py @@ -3,8 +3,8 @@ from collections.abc import Generator from unittest.mock import MagicMock, patch import pytest +from syrupy.assertion import SnapshotAssertion -from homeassistant.components.diagnostics import REDACTED from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -43,25 +43,14 @@ async def test_entry_diagnostics( hass_client: ClientSessionGenerator, owproxy: MagicMock, device_id: str, + snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" setup_owproxy_mock_devices(owproxy, Platform.SENSOR, [device_id]) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { - "entry": { - "data": { - "host": REDACTED, - "port": 1234, - }, - "options": { - "device_options": { - "28.222222222222": {"precision": "temperature9"}, - "28.222222222223": {"precision": "temperature5"}, - } - }, - "title": "Mock Title", - }, - "devices": [DEVICE_DETAILS], - } + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + == snapshot + ) From 0ef29bfc0c2ad17d9e45134bc1ae09eadcd2a788 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Thu, 27 Apr 2023 13:36:43 +0200 Subject: [PATCH 019/822] Add filters to upcoming launch library launches (#92110) Add filters to upcoming launches --- homeassistant/components/launch_library/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/launch_library/__init__.py b/homeassistant/components/launch_library/__init__.py index 34ee74413517..e85c9c81566f 100644 --- a/homeassistant/components/launch_library/__init__.py +++ b/homeassistant/components/launch_library/__init__.py @@ -40,7 +40,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_update() -> LaunchLibraryData: try: return LaunchLibraryData( - upcoming_launches=await launches.upcoming_launches(), + upcoming_launches=await launches.upcoming_launches( + filters={"limit": 1, "hide_recent_previous": "True"}, + ), starship_events=await launches.starship_events(), ) except PyLaunchesException as ex: From 24acc715198f54ea4d6e5da8d0abdbe73ca16dbd Mon Sep 17 00:00:00 2001 From: Doug Hoffman Date: Thu, 27 Apr 2023 07:44:42 -0400 Subject: [PATCH 020/822] Resolve warnings due to incorrectly defined entities in airthings_ble (#92097) * airthings_ble: Define state_class only in SensorEntityDescription * airthings_ble: Drop incompatible device_class for voc --- homeassistant/components/airthings_ble/sensor.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/airthings_ble/sensor.py b/homeassistant/components/airthings_ble/sensor.py index e18d76128f23..b6c8c25491b6 100644 --- a/homeassistant/components/airthings_ble/sensor.py +++ b/homeassistant/components/airthings_ble/sensor.py @@ -65,24 +65,28 @@ SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = { key="temperature", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, name="Temperature", ), "humidity": SensorEntityDescription( key="humidity", device_class=SensorDeviceClass.HUMIDITY, native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, name="Humidity", ), "pressure": SensorEntityDescription( key="pressure", device_class=SensorDeviceClass.PRESSURE, native_unit_of_measurement=UnitOfPressure.MBAR, + state_class=SensorStateClass.MEASUREMENT, name="Pressure", ), "battery": SensorEntityDescription( key="battery", device_class=SensorDeviceClass.BATTERY, native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, name="Battery", ), @@ -90,12 +94,13 @@ SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = { key="co2", device_class=SensorDeviceClass.CO2, native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + state_class=SensorStateClass.MEASUREMENT, name="co2", ), "voc": SensorEntityDescription( key="voc", - device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, + state_class=SensorStateClass.MEASUREMENT, name="VOC", icon="mdi:cloud", ), @@ -103,6 +108,7 @@ SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = { key="illuminance", device_class=SensorDeviceClass.ILLUMINANCE, native_unit_of_measurement=LIGHT_LUX, + state_class=SensorStateClass.MEASUREMENT, name="Illuminance", ), } @@ -150,7 +156,6 @@ class AirthingsSensor( ): """Airthings BLE sensors for the device.""" - _attr_state_class = SensorStateClass.MEASUREMENT _attr_has_entity_name = True def __init__( From f7f950a273600d1af0bc18dfcb15e5eff52f7a4c Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 27 Apr 2023 14:53:12 +0200 Subject: [PATCH 021/822] Update Home Assistant base image to 2023.04.2 - Python 3.11 (#92114) --- build.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/build.yaml b/build.yaml index 0bc38d722699..ab3bf3e67773 100644 --- a/build.yaml +++ b/build.yaml @@ -1,11 +1,11 @@ image: homeassistant/{arch}-homeassistant shadow_repository: ghcr.io/home-assistant build_from: - aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2023.04.0 - armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2023.04.0 - armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2023.04.0 - amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2023.04.0 - i386: ghcr.io/home-assistant/i386-homeassistant-base:2023.04.0 + aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2023.04.2 + armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2023.04.2 + armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2023.04.2 + amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2023.04.2 + i386: ghcr.io/home-assistant/i386-homeassistant-base:2023.04.2 codenotary: signer: notary@home-assistant.io base_image: notary@home-assistant.io From f9ac1f38390306b2bd37b87ce008cb1604c5844b Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Thu, 27 Apr 2023 11:04:22 -0400 Subject: [PATCH 022/822] Add a channel changing API to ZHA (#92076) * Expose channel changing over the websocket API * Expose channel changing as a service * Type annotate some existing unit test fixtures * Add unit tests * Rename `api.change_channel` to `api.async_change_channel` * Expand on channel migration in the service description * Remove channel changing service, we only really need the websocket API * Update homeassistant/components/zha/websocket_api.py * Black --------- Co-authored-by: Paulus Schoutsen Co-authored-by: Paulus Schoutsen --- homeassistant/components/zha/api.py | 23 ++++- homeassistant/components/zha/websocket_api.py | 27 +++++- tests/components/zha/conftest.py | 4 +- tests/components/zha/test_api.py | 47 +++++++++- tests/components/zha/test_websocket_api.py | 93 ++++++++++++++----- 5 files changed, 162 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/zha/api.py b/homeassistant/components/zha/api.py index 652f19d24bac..3d44103e225e 100644 --- a/homeassistant/components/zha/api.py +++ b/homeassistant/components/zha/api.py @@ -2,10 +2,12 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Literal from zigpy.backups import NetworkBackup 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, @@ -111,3 +113,22 @@ def async_get_radio_path( config_entry = _get_config_entry(hass) return config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH] + + +async def async_change_channel( + hass: HomeAssistant, new_channel: int | Literal["auto"] +) -> None: + """Migrate the ZHA network to a new channel.""" + + zha_gateway: ZHAGateway = _get_gateway(hass) + app = zha_gateway.application_controller + + if new_channel == "auto": + channel_energy = await app.energy_scan( + channels=Channels.ALL_CHANNELS, + duration_exp=4, + count=1, + ) + new_channel = pick_optimal_channel(channel_energy) + + await app.move_network_to_channel(new_channel) diff --git a/homeassistant/components/zha/websocket_api.py b/homeassistant/components/zha/websocket_api.py index 322107a074e8..2d4126861b40 100644 --- a/homeassistant/components/zha/websocket_api.py +++ b/homeassistant/components/zha/websocket_api.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio import logging -from typing import TYPE_CHECKING, Any, NamedTuple, TypeVar, cast +from typing import TYPE_CHECKING, Any, Literal, NamedTuple, TypeVar, cast import voluptuous as vol import zigpy.backups @@ -19,7 +19,11 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.service import async_register_admin_service -from .api import async_get_active_network_settings, async_get_radio_type +from .api import ( + async_change_channel, + async_get_active_network_settings, + async_get_radio_type, +) from .core.const import ( ATTR_ARGS, ATTR_ATTRIBUTE, @@ -93,6 +97,7 @@ ATTR_DURATION = "duration" ATTR_GROUP = "group" ATTR_IEEE_ADDRESS = "ieee_address" ATTR_INSTALL_CODE = "install_code" +ATTR_NEW_CHANNEL = "new_channel" ATTR_SOURCE_IEEE = "source_ieee" ATTR_TARGET_IEEE = "target_ieee" ATTR_QR_CODE = "qr_code" @@ -1204,6 +1209,23 @@ async def websocket_restore_network_backup( connection.send_result(msg[ID]) +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/network/change_channel", + vol.Required(ATTR_NEW_CHANNEL): vol.Any("auto", vol.Range(11, 26)), + } +) +@websocket_api.async_response +async def websocket_change_channel( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Migrate the Zigbee network to a new channel.""" + new_channel = cast(Literal["auto"] | int, msg[ATTR_NEW_CHANNEL]) + await async_change_channel(hass, new_channel=new_channel) + connection.send_result(msg[ID]) + + @callback def async_load_api(hass: HomeAssistant) -> None: """Set up the web socket API.""" @@ -1527,6 +1549,7 @@ def async_load_api(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, websocket_list_network_backups) websocket_api.async_register_command(hass, websocket_create_network_backup) websocket_api.async_register_command(hass, websocket_restore_network_backup) + websocket_api.async_register_command(hass, websocket_change_channel) @callback diff --git a/tests/components/zha/conftest.py b/tests/components/zha/conftest.py index 8e31b45afd87..0621c6521a91 100644 --- a/tests/components/zha/conftest.py +++ b/tests/components/zha/conftest.py @@ -116,7 +116,9 @@ 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"): + with patch("zigpy.device.Device.request"), patch.object( + app, "permit", autospec=True + ), 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 b75a65ed5b69..85f85cc04374 100644 --- a/tests/components/zha/test_api.py +++ b/tests/components/zha/test_api.py @@ -1,5 +1,8 @@ """Test ZHA API.""" -from unittest.mock import patch +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import call, patch import pytest import zigpy.backups @@ -10,6 +13,9 @@ from homeassistant.components.zha import api from homeassistant.components.zha.core.const import RadioType from homeassistant.core import HomeAssistant +if TYPE_CHECKING: + from zigpy.application import ControllerApplication + @pytest.fixture(autouse=True) def required_platform_only(): @@ -29,7 +35,7 @@ async def test_async_get_network_settings_active( async def test_async_get_network_settings_inactive( - hass: HomeAssistant, setup_zha, zigpy_app_controller + hass: HomeAssistant, setup_zha, zigpy_app_controller: ControllerApplication ) -> None: """Test reading settings with an inactive ZHA installation.""" await setup_zha() @@ -59,7 +65,7 @@ async def test_async_get_network_settings_inactive( async def test_async_get_network_settings_missing( - hass: HomeAssistant, setup_zha, zigpy_app_controller + hass: HomeAssistant, setup_zha, zigpy_app_controller: ControllerApplication ) -> None: """Test reading settings with an inactive ZHA installation, no valid channel.""" await setup_zha() @@ -100,3 +106,38 @@ async def test_async_get_radio_path_active(hass: HomeAssistant, setup_zha) -> No radio_path = api.async_get_radio_path(hass) assert radio_path == "/dev/ttyUSB0" + + +async def test_change_channel( + hass: HomeAssistant, setup_zha, zigpy_app_controller: ControllerApplication +) -> None: + """Test changing the channel.""" + await setup_zha() + + with patch.object( + zigpy_app_controller, "move_network_to_channel", autospec=True + ) as mock_move_network_to_channel: + await api.async_change_channel(hass, 20) + + assert mock_move_network_to_channel.mock_calls == [call(20)] + + +async def test_change_channel_auto( + hass: HomeAssistant, setup_zha, zigpy_app_controller: ControllerApplication +) -> None: + """Test changing the channel automatically using an energy scan.""" + await setup_zha() + + with patch.object( + zigpy_app_controller, "move_network_to_channel", autospec=True + ) as mock_move_network_to_channel, patch.object( + zigpy_app_controller, + "energy_scan", + autospec=True, + return_value={c: c for c in range(11, 26 + 1)}, + ), patch.object( + api, "pick_optimal_channel", autospec=True, return_value=25 + ): + await api.async_change_channel(hass, "auto") + + assert mock_move_network_to_channel.mock_calls == [call(25)] diff --git a/tests/components/zha/test_websocket_api.py b/tests/components/zha/test_websocket_api.py index 7a24daaa3bac..720cfaaac9b2 100644 --- a/tests/components/zha/test_websocket_api.py +++ b/tests/components/zha/test_websocket_api.py @@ -1,7 +1,10 @@ """Test ZHA WebSocket API.""" +from __future__ import annotations + from binascii import unhexlify from copy import deepcopy -from unittest.mock import AsyncMock, patch +from typing import TYPE_CHECKING +from unittest.mock import ANY, AsyncMock, call, patch import pytest import voluptuous as vol @@ -24,8 +27,6 @@ from homeassistant.components.zha.core.const import ( ATTR_NEIGHBORS, ATTR_QUIRK_APPLIED, CLUSTER_TYPE_IN, - DATA_ZHA, - DATA_ZHA_GATEWAY, EZSP_OVERWRITE_EUI64, GROUP_ID, GROUP_IDS, @@ -59,6 +60,9 @@ from tests.common import MockUser IEEE_SWITCH_DEVICE = "01:2d:6f:00:0a:90:69:e7" IEEE_GROUPABLE_DEVICE = "01:2d:6f:00:0a:90:69:e8" +if TYPE_CHECKING: + from zigpy.application import ControllerApplication + @pytest.fixture(autouse=True) def required_platform_only(): @@ -282,15 +286,17 @@ async def test_get_zha_config_with_alarm( assert configuration == BASE_CUSTOM_CONFIGURATION -async def test_update_zha_config(zha_client, zigpy_app_controller) -> None: +async def test_update_zha_config( + zha_client, app_controller: ControllerApplication +) -> None: """Test updating ZHA custom configuration.""" - configuration = deepcopy(CONFIG_WITH_ALARM_OPTIONS) + configuration: dict = deepcopy(CONFIG_WITH_ALARM_OPTIONS) configuration["data"]["zha_options"]["default_light_transition"] = 10 with patch( "bellows.zigbee.application.ControllerApplication.new", - return_value=zigpy_app_controller, + return_value=app_controller, ): await zha_client.send_json( {ID: 5, TYPE: "zha/configuration/update", "data": configuration["data"]} @@ -463,14 +469,12 @@ async def test_remove_group(zha_client) -> None: @pytest.fixture -async def app_controller(hass, setup_zha): +async def app_controller( + hass: HomeAssistant, setup_zha, zigpy_app_controller: ControllerApplication +) -> ControllerApplication: """Fixture for zigpy Application Controller.""" await setup_zha() - controller = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY].application_controller - p1 = patch.object(controller, "permit") - p2 = patch.object(controller, "permit_with_key", new=AsyncMock()) - with p1, p2: - yield controller + return zigpy_app_controller @pytest.mark.parametrize( @@ -492,7 +496,7 @@ async def app_controller(hass, setup_zha): ) async def test_permit_ha12( hass: HomeAssistant, - app_controller, + app_controller: ControllerApplication, hass_admin_user: MockUser, params, duration, @@ -532,7 +536,7 @@ IC_TEST_PARAMS = ( @pytest.mark.parametrize(("params", "src_ieee", "code"), IC_TEST_PARAMS) async def test_permit_with_install_code( hass: HomeAssistant, - app_controller, + app_controller: ControllerApplication, hass_admin_user: MockUser, params, src_ieee, @@ -587,7 +591,10 @@ IC_FAIL_PARAMS = ( @pytest.mark.parametrize("params", IC_FAIL_PARAMS) async def test_permit_with_install_code_fail( - hass: HomeAssistant, app_controller, hass_admin_user: MockUser, params + hass: HomeAssistant, + app_controller: ControllerApplication, + hass_admin_user: MockUser, + params, ) -> None: """Test permit service with install code.""" @@ -626,7 +633,7 @@ IC_QR_CODE_TEST_PARAMS = ( @pytest.mark.parametrize(("params", "src_ieee", "code"), IC_QR_CODE_TEST_PARAMS) async def test_permit_with_qr_code( hass: HomeAssistant, - app_controller, + app_controller: ControllerApplication, hass_admin_user: MockUser, params, src_ieee, @@ -646,7 +653,7 @@ async def test_permit_with_qr_code( @pytest.mark.parametrize(("params", "src_ieee", "code"), IC_QR_CODE_TEST_PARAMS) async def test_ws_permit_with_qr_code( - app_controller, zha_client, params, src_ieee, code + app_controller: ControllerApplication, zha_client, params, src_ieee, code ) -> None: """Test permit service with install code from qr code.""" @@ -668,7 +675,7 @@ async def test_ws_permit_with_qr_code( @pytest.mark.parametrize("params", IC_FAIL_PARAMS) async def test_ws_permit_with_install_code_fail( - app_controller, zha_client, params + app_controller: ControllerApplication, zha_client, params ) -> None: """Test permit ws service with install code.""" @@ -703,7 +710,7 @@ async def test_ws_permit_with_install_code_fail( ), ) async def test_ws_permit_ha12( - app_controller, zha_client, params, duration, node + app_controller: ControllerApplication, zha_client, params, duration, node ) -> None: """Test permit ws service.""" @@ -722,7 +729,9 @@ async def test_ws_permit_ha12( assert app_controller.permit_with_key.call_count == 0 -async def test_get_network_settings(app_controller, zha_client) -> None: +async def test_get_network_settings( + app_controller: ControllerApplication, zha_client +) -> None: """Test current network settings are returned.""" await app_controller.backups.create_backup() @@ -737,7 +746,9 @@ async def test_get_network_settings(app_controller, zha_client) -> None: assert "network_info" in msg["result"]["settings"] -async def test_list_network_backups(app_controller, zha_client) -> None: +async def test_list_network_backups( + app_controller: ControllerApplication, zha_client +) -> None: """Test backups are serialized.""" await app_controller.backups.create_backup() @@ -751,7 +762,9 @@ async def test_list_network_backups(app_controller, zha_client) -> None: assert "network_info" in msg["result"][0] -async def test_create_network_backup(app_controller, zha_client) -> None: +async def test_create_network_backup( + app_controller: ControllerApplication, zha_client +) -> None: """Test creating backup.""" assert not app_controller.backups.backups @@ -765,7 +778,9 @@ async def test_create_network_backup(app_controller, zha_client) -> None: assert "backup" in msg["result"] and "is_complete" in msg["result"] -async def test_restore_network_backup_success(app_controller, zha_client) -> None: +async def test_restore_network_backup_success( + app_controller: ControllerApplication, zha_client +) -> None: """Test successfully restoring a backup.""" backup = zigpy.backups.NetworkBackup() @@ -789,7 +804,7 @@ async def test_restore_network_backup_success(app_controller, zha_client) -> Non async def test_restore_network_backup_force_write_eui64( - app_controller, zha_client + app_controller: ControllerApplication, zha_client ) -> None: """Test successfully restoring a backup.""" @@ -821,7 +836,9 @@ async def test_restore_network_backup_force_write_eui64( @patch("zigpy.backups.NetworkBackup.from_dict", new=lambda v: v) -async def test_restore_network_backup_failure(app_controller, zha_client) -> None: +async def test_restore_network_backup_failure( + app_controller: ControllerApplication, zha_client +) -> None: """Test successfully restoring a backup.""" with patch.object( @@ -840,3 +857,29 @@ async def test_restore_network_backup_failure(app_controller, zha_client) -> Non assert msg["type"] == const.TYPE_RESULT assert not msg["success"] assert msg["error"]["code"] == const.ERR_INVALID_FORMAT + + +@pytest.mark.parametrize("new_channel", ["auto", 15]) +async def test_websocket_change_channel( + new_channel: int | str, app_controller: ControllerApplication, zha_client +) -> None: + """Test websocket API to migrate the network to a new channel.""" + + with patch( + "homeassistant.components.zha.websocket_api.async_change_channel", + autospec=True, + ) as change_channel_mock: + await zha_client.send_json( + { + ID: 6, + TYPE: f"{DOMAIN}/network/change_channel", + "new_channel": new_channel, + } + ) + msg = await zha_client.receive_json() + + assert msg["id"] == 6 + assert msg["type"] == const.TYPE_RESULT + assert msg["success"] + + change_channel_mock.mock_calls == [call(ANY, new_channel)] From 3aa82e122cbc7a5c39863084e3848a4e9b7cce7e Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 27 Apr 2023 17:10:07 +0200 Subject: [PATCH 023/822] Remove usbutils from machine builds (#91942) --- machine/generic-x86-64 | 3 +-- machine/intel-nuc | 3 +-- machine/khadas-vim3 | 4 +--- machine/odroid-c2 | 3 --- machine/odroid-c4 | 3 --- machine/odroid-m1 | 3 --- machine/odroid-n2 | 3 --- machine/odroid-xu | 3 --- machine/qemuarm | 3 --- machine/qemuarm-64 | 3 --- machine/qemux86 | 3 --- machine/qemux86-64 | 3 --- machine/raspberrypi | 3 +-- machine/raspberrypi2 | 3 +-- machine/raspberrypi3 | 1 - machine/raspberrypi3-64 | 1 - machine/raspberrypi4 | 1 - machine/raspberrypi4-64 | 1 - machine/tinker | 3 +-- machine/yellow | 3 +-- 20 files changed, 7 insertions(+), 46 deletions(-) diff --git a/machine/generic-x86-64 b/machine/generic-x86-64 index b7fbac2e8edc..bbd72e93d513 100644 --- a/machine/generic-x86-64 +++ b/machine/generic-x86-64 @@ -2,5 +2,4 @@ ARG BUILD_VERSION FROM homeassistant/amd64-homeassistant:$BUILD_VERSION RUN apk --no-cache add \ - libva-intel-driver \ - usbutils + libva-intel-driver diff --git a/machine/intel-nuc b/machine/intel-nuc index 5e1b7f957d1c..9b8a2b3eef97 100644 --- a/machine/intel-nuc +++ b/machine/intel-nuc @@ -5,5 +5,4 @@ FROM homeassistant/amd64-homeassistant:$BUILD_VERSION # changes in generic-x86-64 as well. RUN apk --no-cache add \ - libva-intel-driver \ - usbutils + libva-intel-driver diff --git a/machine/khadas-vim3 b/machine/khadas-vim3 index 2109262b3424..d52863926598 100644 --- a/machine/khadas-vim3 +++ b/machine/khadas-vim3 @@ -1,9 +1,7 @@ ARG BUILD_VERSION FROM homeassistant/aarch64-homeassistant:$BUILD_VERSION -RUN apk --no-cache add \ - usbutils \ - && pip3 install --no-cache-dir --no-index --only-binary=:all: --find-links "${WHEELS_LINKS}" \ +RUN pip3 install --no-cache-dir --no-index --only-binary=:all: --find-links "${WHEELS_LINKS}" \ pybluez \ -c /usr/src/homeassistant/requirements_all.txt \ --use-deprecated=legacy-resolver diff --git a/machine/odroid-c2 b/machine/odroid-c2 index be07d6c8abae..af934f31f0e3 100644 --- a/machine/odroid-c2 +++ b/machine/odroid-c2 @@ -1,5 +1,2 @@ ARG BUILD_VERSION FROM homeassistant/aarch64-homeassistant:$BUILD_VERSION - -RUN apk --no-cache add \ - usbutils diff --git a/machine/odroid-c4 b/machine/odroid-c4 index be07d6c8abae..af934f31f0e3 100644 --- a/machine/odroid-c4 +++ b/machine/odroid-c4 @@ -1,5 +1,2 @@ ARG BUILD_VERSION FROM homeassistant/aarch64-homeassistant:$BUILD_VERSION - -RUN apk --no-cache add \ - usbutils diff --git a/machine/odroid-m1 b/machine/odroid-m1 index be07d6c8abae..af934f31f0e3 100644 --- a/machine/odroid-m1 +++ b/machine/odroid-m1 @@ -1,5 +1,2 @@ ARG BUILD_VERSION FROM homeassistant/aarch64-homeassistant:$BUILD_VERSION - -RUN apk --no-cache add \ - usbutils diff --git a/machine/odroid-n2 b/machine/odroid-n2 index be07d6c8abae..af934f31f0e3 100644 --- a/machine/odroid-n2 +++ b/machine/odroid-n2 @@ -1,5 +1,2 @@ ARG BUILD_VERSION FROM homeassistant/aarch64-homeassistant:$BUILD_VERSION - -RUN apk --no-cache add \ - usbutils diff --git a/machine/odroid-xu b/machine/odroid-xu index 3aa428d3f52b..57f6d0cddb8e 100644 --- a/machine/odroid-xu +++ b/machine/odroid-xu @@ -1,5 +1,2 @@ ARG BUILD_VERSION FROM homeassistant/armv7-homeassistant:$BUILD_VERSION - -RUN apk --no-cache add \ - usbutils diff --git a/machine/qemuarm b/machine/qemuarm index e00c945e945b..54fc002097a4 100644 --- a/machine/qemuarm +++ b/machine/qemuarm @@ -1,5 +1,2 @@ ARG BUILD_VERSION FROM homeassistant/armhf-homeassistant:$BUILD_VERSION - -RUN apk --no-cache add \ - usbutils diff --git a/machine/qemuarm-64 b/machine/qemuarm-64 index be07d6c8abae..af934f31f0e3 100644 --- a/machine/qemuarm-64 +++ b/machine/qemuarm-64 @@ -1,5 +1,2 @@ ARG BUILD_VERSION FROM homeassistant/aarch64-homeassistant:$BUILD_VERSION - -RUN apk --no-cache add \ - usbutils diff --git a/machine/qemux86 b/machine/qemux86 index 1b5350df4c8c..23264cb6b18e 100644 --- a/machine/qemux86 +++ b/machine/qemux86 @@ -1,5 +1,2 @@ ARG BUILD_VERSION FROM homeassistant/i386-homeassistant:$BUILD_VERSION - -RUN apk --no-cache add \ - usbutils diff --git a/machine/qemux86-64 b/machine/qemux86-64 index 541e994b9678..3e7a43d13354 100644 --- a/machine/qemux86-64 +++ b/machine/qemux86-64 @@ -1,5 +1,2 @@ ARG BUILD_VERSION FROM homeassistant/amd64-homeassistant:$BUILD_VERSION - -RUN apk --no-cache add \ - usbutils diff --git a/machine/raspberrypi b/machine/raspberrypi index 8ea2d08bba70..876fcfce21e3 100644 --- a/machine/raspberrypi +++ b/machine/raspberrypi @@ -3,8 +3,7 @@ FROM homeassistant/armhf-homeassistant:$BUILD_VERSION RUN apk --no-cache add \ raspberrypi \ - raspberrypi-libs \ - usbutils + raspberrypi-libs ## # Set symlinks for raspberry pi camera binaries. diff --git a/machine/raspberrypi2 b/machine/raspberrypi2 index 45f7a9c80d1f..5d109e240b7b 100644 --- a/machine/raspberrypi2 +++ b/machine/raspberrypi2 @@ -3,8 +3,7 @@ FROM homeassistant/armv7-homeassistant:$BUILD_VERSION RUN apk --no-cache add \ raspberrypi \ - raspberrypi-libs \ - usbutils + raspberrypi-libs ## # Set symlinks for raspberry pi binaries. diff --git a/machine/raspberrypi3 b/machine/raspberrypi3 index 251825867079..598898199e4f 100644 --- a/machine/raspberrypi3 +++ b/machine/raspberrypi3 @@ -4,7 +4,6 @@ FROM homeassistant/armv7-homeassistant:$BUILD_VERSION RUN apk --no-cache add \ raspberrypi \ raspberrypi-libs \ - usbutils \ && pip3 install --no-cache-dir --no-index --only-binary=:all: --find-links "${WHEELS_LINKS}" \ pybluez \ -c /usr/src/homeassistant/requirements_all.txt \ diff --git a/machine/raspberrypi3-64 b/machine/raspberrypi3-64 index 48838cc13e1f..3db316314261 100644 --- a/machine/raspberrypi3-64 +++ b/machine/raspberrypi3-64 @@ -4,7 +4,6 @@ FROM homeassistant/aarch64-homeassistant:$BUILD_VERSION RUN apk --no-cache add \ raspberrypi \ raspberrypi-libs \ - usbutils \ && pip3 install --no-cache-dir --no-index --only-binary=:all: --find-links "${WHEELS_LINKS}" \ pybluez \ -c /usr/src/homeassistant/requirements_all.txt \ diff --git a/machine/raspberrypi4 b/machine/raspberrypi4 index 251825867079..598898199e4f 100644 --- a/machine/raspberrypi4 +++ b/machine/raspberrypi4 @@ -4,7 +4,6 @@ FROM homeassistant/armv7-homeassistant:$BUILD_VERSION RUN apk --no-cache add \ raspberrypi \ raspberrypi-libs \ - usbutils \ && pip3 install --no-cache-dir --no-index --only-binary=:all: --find-links "${WHEELS_LINKS}" \ pybluez \ -c /usr/src/homeassistant/requirements_all.txt \ diff --git a/machine/raspberrypi4-64 b/machine/raspberrypi4-64 index 48838cc13e1f..3db316314261 100644 --- a/machine/raspberrypi4-64 +++ b/machine/raspberrypi4-64 @@ -4,7 +4,6 @@ FROM homeassistant/aarch64-homeassistant:$BUILD_VERSION RUN apk --no-cache add \ raspberrypi \ raspberrypi-libs \ - usbutils \ && pip3 install --no-cache-dir --no-index --only-binary=:all: --find-links "${WHEELS_LINKS}" \ pybluez \ -c /usr/src/homeassistant/requirements_all.txt \ diff --git a/machine/tinker b/machine/tinker index 2fb01f2b5453..60a99734fe40 100644 --- a/machine/tinker +++ b/machine/tinker @@ -1,8 +1,7 @@ ARG BUILD_VERSION FROM homeassistant/armv7-homeassistant:$BUILD_VERSION -RUN apk --no-cache add usbutils \ - && pip3 install --no-cache-dir --no-index --only-binary=:all: --find-links "${WHEELS_LINKS}" \ +RUN pip3 install --no-cache-dir --no-index --only-binary=:all: --find-links "${WHEELS_LINKS}" \ pybluez \ -c /usr/src/homeassistant/requirements_all.txt \ --use-deprecated=legacy-resolver diff --git a/machine/yellow b/machine/yellow index d8e7421f9b1f..9205b90389a3 100644 --- a/machine/yellow +++ b/machine/yellow @@ -3,8 +3,7 @@ FROM homeassistant/aarch64-homeassistant:$BUILD_VERSION RUN apk --no-cache add \ raspberrypi \ - raspberrypi-libs \ - usbutils + raspberrypi-libs ## # Set symlinks for raspberry pi binaries. From 2522c6d697833d39fc2ab537aa1e94949c7edfe1 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 27 Apr 2023 17:10:29 +0200 Subject: [PATCH 024/822] Add WS command cloud/alexa/entities/get (#92121) * Add WS command cloud/alexa/entities/get * Fix bugs, add test --- .../components/cloud/alexa_config.py | 6 +- homeassistant/components/cloud/http_api.py | 42 +++++++++++++ tests/components/cloud/test_http_api.py | 61 +++++++++++++++++++ 3 files changed, 106 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/cloud/alexa_config.py b/homeassistant/components/cloud/alexa_config.py index 7acfbbb7f6b2..9c691ebed557 100644 --- a/homeassistant/components/cloud/alexa_config.py +++ b/homeassistant/components/cloud/alexa_config.py @@ -94,7 +94,7 @@ SUPPORTED_SENSOR_DEVICE_CLASSES = { } -def _supported_legacy(hass: HomeAssistant, entity_id: str) -> bool: +def entity_supported(hass: HomeAssistant, entity_id: str) -> bool: """Return if the entity is supported. This is called when migrating from legacy config format to avoid exposing @@ -249,12 +249,12 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): # Backwards compat if (default_expose := self._prefs.alexa_default_expose) is None: - return not auxiliary_entity and _supported_legacy(self.hass, entity_id) + return not auxiliary_entity and entity_supported(self.hass, entity_id) return ( not auxiliary_entity and split_entity_id(entity_id)[0] in default_expose - and _supported_legacy(self.hass, entity_id) + and entity_supported(self.hass, entity_id) ) def should_expose(self, entity_id): diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 8d6c4e65e3c1..8bc31f7b8625 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -29,6 +29,7 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util.location import async_detect_location_info +from .alexa_config import entity_supported as entity_supported_by_alexa from .const import ( DOMAIN, PREF_ALEXA_REPORT_STATE, @@ -73,6 +74,7 @@ async def async_setup(hass): websocket_api.async_register_command(hass, google_assistant_list) websocket_api.async_register_command(hass, google_assistant_update) + websocket_api.async_register_command(hass, alexa_get) websocket_api.async_register_command(hass, alexa_list) websocket_api.async_register_command(hass, alexa_sync) @@ -668,6 +670,46 @@ async def google_assistant_update( connection.send_result(msg["id"]) +@websocket_api.require_admin +@_require_cloud_login +@websocket_api.websocket_command( + { + "type": "cloud/alexa/entities/get", + "entity_id": str, + } +) +@websocket_api.async_response +@_ws_handle_cloud_errors +async def alexa_get( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Get data for a single alexa entity.""" + entity_registry = er.async_get(hass) + entity_id: str = msg["entity_id"] + + if not entity_registry.async_is_registered(entity_id): + connection.send_error( + msg["id"], + websocket_api.const.ERR_NOT_FOUND, + f"{entity_id} not in the entity registry", + ) + return + + if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES or not entity_supported_by_alexa( + hass, entity_id + ): + connection.send_error( + msg["id"], + websocket_api.const.ERR_NOT_SUPPORTED, + f"{entity_id} not supported by Alexa", + ) + return + + connection.send_result(msg["id"]) + + @websocket_api.require_admin @_require_cloud_login @websocket_api.websocket_command({"type": "cloud/alexa/entities"}) diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 351caff883f0..ff4b9be4d3f6 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -938,6 +938,67 @@ async def test_list_alexa_entities( } +async def test_get_alexa_entity( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + setup_api, + mock_cloud_login, +) -> None: + """Test that we can get an Alexa entity.""" + client = await hass_ws_client(hass) + + # Test getting an unknown entity + await client.send_json_auto_id( + {"type": "cloud/alexa/entities/get", "entity_id": "light.kitchen"} + ) + response = await client.receive_json() + assert not response["success"] + assert response["error"] == { + "code": "not_found", + "message": "light.kitchen not in the entity registry", + } + + # Test getting a blocked entity + entity_registry.async_get_or_create( + "group", "test", "unique", suggested_object_id="all_locks" + ) + hass.states.async_set("group.all_locks", "bla") + await client.send_json_auto_id( + {"type": "cloud/alexa/entities/get", "entity_id": "group.all_locks"} + ) + response = await client.receive_json() + assert not response["success"] + assert response["error"] == { + "code": "not_supported", + "message": "group.all_locks not supported by Alexa", + } + + entity_registry.async_get_or_create( + "light", "test", "unique", suggested_object_id="kitchen" + ) + entity_registry.async_get_or_create( + "water_heater", "test", "unique", suggested_object_id="basement" + ) + + await client.send_json_auto_id( + {"type": "cloud/alexa/entities/get", "entity_id": "light.kitchen"} + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] is None + + await client.send_json_auto_id( + {"type": "cloud/alexa/entities/get", "entity_id": "water_heater.basement"} + ) + response = await client.receive_json() + assert not response["success"] + assert response["error"] == { + "code": "not_supported", + "message": "water_heater.basement not supported by Alexa", + } + + async def test_update_alexa_entity( hass: HomeAssistant, entity_registry: er.EntityRegistry, From 15c2ed7b04699a8c8e9494921a765237afb63562 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 27 Apr 2023 18:02:26 +0200 Subject: [PATCH 025/822] Cleanup pybluez from machine builds (#92122) --- machine/khadas-vim3 | 5 ----- machine/raspberrypi3 | 6 +----- machine/raspberrypi3-64 | 6 +----- machine/raspberrypi4 | 6 +----- machine/raspberrypi4-64 | 6 +----- machine/tinker | 5 ----- 6 files changed, 4 insertions(+), 30 deletions(-) diff --git a/machine/khadas-vim3 b/machine/khadas-vim3 index d52863926598..af934f31f0e3 100644 --- a/machine/khadas-vim3 +++ b/machine/khadas-vim3 @@ -1,7 +1,2 @@ ARG BUILD_VERSION FROM homeassistant/aarch64-homeassistant:$BUILD_VERSION - -RUN pip3 install --no-cache-dir --no-index --only-binary=:all: --find-links "${WHEELS_LINKS}" \ - pybluez \ - -c /usr/src/homeassistant/requirements_all.txt \ - --use-deprecated=legacy-resolver diff --git a/machine/raspberrypi3 b/machine/raspberrypi3 index 598898199e4f..5d109e240b7b 100644 --- a/machine/raspberrypi3 +++ b/machine/raspberrypi3 @@ -3,11 +3,7 @@ FROM homeassistant/armv7-homeassistant:$BUILD_VERSION RUN apk --no-cache add \ raspberrypi \ - raspberrypi-libs \ - && pip3 install --no-cache-dir --no-index --only-binary=:all: --find-links "${WHEELS_LINKS}" \ - pybluez \ - -c /usr/src/homeassistant/requirements_all.txt \ - --use-deprecated=legacy-resolver + raspberrypi-libs ## # Set symlinks for raspberry pi binaries. diff --git a/machine/raspberrypi3-64 b/machine/raspberrypi3-64 index 3db316314261..9205b90389a3 100644 --- a/machine/raspberrypi3-64 +++ b/machine/raspberrypi3-64 @@ -3,11 +3,7 @@ FROM homeassistant/aarch64-homeassistant:$BUILD_VERSION RUN apk --no-cache add \ raspberrypi \ - raspberrypi-libs \ - && pip3 install --no-cache-dir --no-index --only-binary=:all: --find-links "${WHEELS_LINKS}" \ - pybluez \ - -c /usr/src/homeassistant/requirements_all.txt \ - --use-deprecated=legacy-resolver + raspberrypi-libs ## # Set symlinks for raspberry pi binaries. diff --git a/machine/raspberrypi4 b/machine/raspberrypi4 index 598898199e4f..5d109e240b7b 100644 --- a/machine/raspberrypi4 +++ b/machine/raspberrypi4 @@ -3,11 +3,7 @@ FROM homeassistant/armv7-homeassistant:$BUILD_VERSION RUN apk --no-cache add \ raspberrypi \ - raspberrypi-libs \ - && pip3 install --no-cache-dir --no-index --only-binary=:all: --find-links "${WHEELS_LINKS}" \ - pybluez \ - -c /usr/src/homeassistant/requirements_all.txt \ - --use-deprecated=legacy-resolver + raspberrypi-libs ## # Set symlinks for raspberry pi binaries. diff --git a/machine/raspberrypi4-64 b/machine/raspberrypi4-64 index 3db316314261..9205b90389a3 100644 --- a/machine/raspberrypi4-64 +++ b/machine/raspberrypi4-64 @@ -3,11 +3,7 @@ FROM homeassistant/aarch64-homeassistant:$BUILD_VERSION RUN apk --no-cache add \ raspberrypi \ - raspberrypi-libs \ - && pip3 install --no-cache-dir --no-index --only-binary=:all: --find-links "${WHEELS_LINKS}" \ - pybluez \ - -c /usr/src/homeassistant/requirements_all.txt \ - --use-deprecated=legacy-resolver + raspberrypi-libs ## # Set symlinks for raspberry pi binaries. diff --git a/machine/tinker b/machine/tinker index 60a99734fe40..57f6d0cddb8e 100644 --- a/machine/tinker +++ b/machine/tinker @@ -1,7 +1,2 @@ ARG BUILD_VERSION FROM homeassistant/armv7-homeassistant:$BUILD_VERSION - -RUN pip3 install --no-cache-dir --no-index --only-binary=:all: --find-links "${WHEELS_LINKS}" \ - pybluez \ - -c /usr/src/homeassistant/requirements_all.txt \ - --use-deprecated=legacy-resolver From 47b289b6328982b3c32f5a119d51c088c7d0a4cd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 27 Apr 2023 18:52:43 +0200 Subject: [PATCH 026/822] Bump sqlalchemy to 2.0.11 to fix a critical regression with postgresql (#92126) --- homeassistant/components/recorder/manifest.json | 2 +- homeassistant/components/sql/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index 5ca56de513e9..d08a3b45b340 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.10", + "sqlalchemy==2.0.11", "fnv-hash-fast==0.3.1", "psutil-home-assistant==0.0.1" ] diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index 61328de95336..af8394e2ad7d 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.10"] + "requirements": ["sqlalchemy==2.0.11"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 3a9465869c2b..8e1140c0534d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -45,7 +45,7 @@ pyudev==0.23.2 pyyaml==6.0 requests==2.28.2 scapy==2.5.0 -sqlalchemy==2.0.10 +sqlalchemy==2.0.11 typing-extensions>=4.5.0,<5.0 ulid-transform==0.7.0 voluptuous-serialize==2.6.0 diff --git a/requirements_all.txt b/requirements_all.txt index 429317a623b0..849a333f7d10 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2406,7 +2406,7 @@ spotipy==2.23.0 # homeassistant.components.recorder # homeassistant.components.sql -sqlalchemy==2.0.10 +sqlalchemy==2.0.11 # homeassistant.components.srp_energy srpenergy==1.3.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f5e836dd6d8a..5245f2b6960c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1730,7 +1730,7 @@ spotipy==2.23.0 # homeassistant.components.recorder # homeassistant.components.sql -sqlalchemy==2.0.10 +sqlalchemy==2.0.11 # homeassistant.components.srp_energy srpenergy==1.3.6 From 52816a8cd447cd9fabb9cc2b9ffc538b28c98d2e Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Thu, 27 Apr 2023 19:07:56 +0200 Subject: [PATCH 027/822] Update frontend to 20230427.0 (#92123) --- 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 e1bf0329de70..409f70f554a6 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==20230426.0"] + "requirements": ["home-assistant-frontend==20230427.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 8e1140c0534d..6f5adea209c1 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -25,7 +25,7 @@ ha-av==10.0.0 hass-nabucasa==0.66.2 hassil==1.0.6 home-assistant-bluetooth==1.10.0 -home-assistant-frontend==20230426.0 +home-assistant-frontend==20230427.0 home-assistant-intents==2023.4.26 httpx==0.24.0 ifaddr==0.1.7 diff --git a/requirements_all.txt b/requirements_all.txt index 849a333f7d10..9672ed19c9ab 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -911,7 +911,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230426.0 +home-assistant-frontend==20230427.0 # homeassistant.components.conversation home-assistant-intents==2023.4.26 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5245f2b6960c..f1049c0ad30a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -700,7 +700,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230426.0 +home-assistant-frontend==20230427.0 # homeassistant.components.conversation home-assistant-intents==2023.4.26 From ac21f69ad0b85bff0a5c5d00e54e4e65c290ec31 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 27 Apr 2023 19:51:26 +0200 Subject: [PATCH 028/822] Add add-on discovery URL and title to Wyoming integration (#92129) --- homeassistant/components/wyoming/config_flow.py | 6 ++++++ tests/components/wyoming/snapshots/test_config_flow.ambr | 8 ++++++++ 2 files changed, 14 insertions(+) diff --git a/homeassistant/components/wyoming/config_flow.py b/homeassistant/components/wyoming/config_flow.py index e1b41b540587..d7d5d0278e89 100644 --- a/homeassistant/components/wyoming/config_flow.py +++ b/homeassistant/components/wyoming/config_flow.py @@ -69,6 +69,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._abort_if_unique_id_configured() self._hassio_discovery = discovery_info + self.context.update( + { + "title_placeholders": {"name": discovery_info.name}, + "configuration_url": f"homeassistant://hassio/addon/{discovery_info.slug}/info", + } + ) return await self.async_step_hassio_confirm() async def async_step_hassio_confirm( diff --git a/tests/components/wyoming/snapshots/test_config_flow.ambr b/tests/components/wyoming/snapshots/test_config_flow.ambr index 685ea38f84e9..d4220a397242 100644 --- a/tests/components/wyoming/snapshots/test_config_flow.ambr +++ b/tests/components/wyoming/snapshots/test_config_flow.ambr @@ -40,7 +40,11 @@ # name: test_hassio_addon_discovery[info0] FlowResultSnapshot({ 'context': dict({ + 'configuration_url': 'homeassistant://hassio/addon/mock_piper/info', 'source': 'hassio', + 'title_placeholders': dict({ + 'name': 'Piper', + }), 'unique_id': '1234', }), 'data': dict({ @@ -78,7 +82,11 @@ # name: test_hassio_addon_discovery[info1] FlowResultSnapshot({ 'context': dict({ + 'configuration_url': 'homeassistant://hassio/addon/mock_piper/info', 'source': 'hassio', + 'title_placeholders': dict({ + 'name': 'Piper', + }), 'unique_id': '1234', }), 'data': dict({ From efec50c8f079b8172a58973d52d2d9987894b2b5 Mon Sep 17 00:00:00 2001 From: Luke Date: Thu, 27 Apr 2023 15:10:34 -0400 Subject: [PATCH 029/822] Bump roborock to 0.8.1 for beta fixes (#92131) * bump to 0.8.1 * add tests for new config flow errors * removed logs for known errors --- .../components/roborock/config_flow.py | 20 ++++++++++++++++--- .../components/roborock/coordinator.py | 10 ++++------ .../components/roborock/manifest.json | 2 +- homeassistant/components/roborock/models.py | 4 ++-- .../components/roborock/strings.json | 3 +++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/roborock/mock_data.py | 4 ++-- tests/components/roborock/test_config_flow.py | 16 ++++++++++++--- 9 files changed, 44 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/roborock/config_flow.py b/homeassistant/components/roborock/config_flow.py index d0c2147c1ee0..fcfad6e8cd36 100644 --- a/homeassistant/components/roborock/config_flow.py +++ b/homeassistant/components/roborock/config_flow.py @@ -6,7 +6,13 @@ from typing import Any from roborock.api import RoborockApiClient from roborock.containers import UserData -from roborock.exceptions import RoborockException +from roborock.exceptions import ( + RoborockAccountDoesNotExist, + RoborockException, + RoborockInvalidCode, + RoborockInvalidEmail, + RoborockUrlException, +) import voluptuous as vol from homeassistant import config_entries @@ -43,9 +49,15 @@ class RoborockFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self._client = RoborockApiClient(username) try: await self._client.request_code() + except RoborockAccountDoesNotExist: + errors["base"] = "invalid_email" + except RoborockUrlException: + errors["base"] = "unknown_url" + except RoborockInvalidEmail: + errors["base"] = "invalid_email_format" except RoborockException as ex: _LOGGER.exception(ex) - errors["base"] = "invalid_email" + errors["base"] = "unknown_roborock" except Exception as ex: # pylint: disable=broad-except _LOGGER.exception(ex) errors["base"] = "unknown" @@ -70,9 +82,11 @@ class RoborockFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): _LOGGER.debug("Logging into Roborock account using email provided code") try: login_data = await self._client.code_login(code) + except RoborockInvalidCode: + errors["base"] = "invalid_code" except RoborockException as ex: _LOGGER.exception(ex) - errors["base"] = "invalid_code" + errors["base"] = "unknown_roborock" except Exception as ex: # pylint: disable=broad-except _LOGGER.exception(ex) errors["base"] = "unknown" diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index 997c0a6acb87..433b46d2899d 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -13,7 +13,7 @@ from roborock.containers import ( ) from roborock.exceptions import RoborockException from roborock.local_api import RoborockLocalClient -from roborock.typing import RoborockDeviceProp +from roborock.typing import DeviceProp from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -26,9 +26,7 @@ SCAN_INTERVAL = timedelta(seconds=30) _LOGGER = logging.getLogger(__name__) -class RoborockDataUpdateCoordinator( - DataUpdateCoordinator[dict[str, RoborockDeviceProp]] -): +class RoborockDataUpdateCoordinator(DataUpdateCoordinator[dict[str, DeviceProp]]): """Class to manage fetching data from the API.""" def __init__( @@ -50,7 +48,7 @@ class RoborockDataUpdateCoordinator( device, networking, product_info[device.product_id], - RoborockDeviceProp(), + DeviceProp(), ) local_devices_info[device.duid] = RoborockLocalDeviceInfo( device, networking @@ -71,7 +69,7 @@ class RoborockDataUpdateCoordinator( else: device_info.props = device_prop - async def _async_update_data(self) -> dict[str, RoborockDeviceProp]: + async def _async_update_data(self) -> dict[str, DeviceProp]: """Update data via library.""" try: await asyncio.gather( diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index 20dee34db053..f433aee25c48 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.6.5"] + "requirements": ["python-roborock==0.8.1"] } diff --git a/homeassistant/components/roborock/models.py b/homeassistant/components/roborock/models.py index ae0adb4ad7d3..0377cebd425e 100644 --- a/homeassistant/components/roborock/models.py +++ b/homeassistant/components/roborock/models.py @@ -2,7 +2,7 @@ from dataclasses import dataclass from roborock.containers import HomeDataDevice, HomeDataProduct, NetworkInfo -from roborock.typing import RoborockDeviceProp +from roborock.typing import DeviceProp @dataclass @@ -12,4 +12,4 @@ class RoborockHassDeviceInfo: device: HomeDataDevice network_info: NetworkInfo product: HomeDataProduct - props: RoborockDeviceProp + props: DeviceProp diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 7e755a0c41f4..6bd19787d203 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -17,6 +17,9 @@ "error": { "invalid_code": "The code you entered was incorrect, please check it and try again.", "invalid_email": "There is no account associated with the email you entered, please try again.", + "invalid_email_format": "There is an issue with the formatting of your email - please try again.", + "unknown_roborock": "There was an unknown roborock exception - please check your logs.", + "unknown_url": "There was an issue determining the correct url for your roborock account - please check your logs.", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { diff --git a/requirements_all.txt b/requirements_all.txt index 9672ed19c9ab..d1523565445d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2108,7 +2108,7 @@ python-qbittorrent==0.4.2 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==0.6.5 +python-roborock==0.8.1 # homeassistant.components.smarttub python-smarttub==0.0.33 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f1049c0ad30a..6a91880a6d0d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1516,7 +1516,7 @@ python-picnic-api==1.1.0 python-qbittorrent==0.4.2 # homeassistant.components.roborock -python-roborock==0.6.5 +python-roborock==0.8.1 # homeassistant.components.smarttub python-smarttub==0.0.33 diff --git a/tests/components/roborock/mock_data.py b/tests/components/roborock/mock_data.py index d6afff440f72..cbd5ef379e87 100644 --- a/tests/components/roborock/mock_data.py +++ b/tests/components/roborock/mock_data.py @@ -10,7 +10,7 @@ from roborock.containers import ( Status, UserData, ) -from roborock.typing import RoborockDeviceProp +from roborock.typing import DeviceProp # All data is based on a U.S. customer with a Roborock S7 MaxV Ultra USER_EMAIL = "user@domain.com" @@ -367,4 +367,4 @@ STATUS = Status.from_dict( } ) -PROP = RoborockDeviceProp(STATUS, DND_TIMER, CLEAN_SUMMARY, CONSUMABLE, CLEAN_RECORD) +PROP = DeviceProp(STATUS, DND_TIMER, CLEAN_SUMMARY, CONSUMABLE, CLEAN_RECORD) diff --git a/tests/components/roborock/test_config_flow.py b/tests/components/roborock/test_config_flow.py index d319f0e165df..2f297135d153 100644 --- a/tests/components/roborock/test_config_flow.py +++ b/tests/components/roborock/test_config_flow.py @@ -2,7 +2,13 @@ from unittest.mock import patch import pytest -from roborock.exceptions import RoborockException +from roborock.exceptions import ( + RoborockAccountDoesNotExist, + RoborockException, + RoborockInvalidCode, + RoborockInvalidEmail, + RoborockUrlException, +) from homeassistant import config_entries, data_entry_flow from homeassistant.components.roborock.const import CONF_ENTRY_CODE, DOMAIN @@ -55,7 +61,10 @@ async def test_config_flow_success( "request_code_errors", ), [ - (RoborockException(), {"base": "invalid_email"}), + (RoborockException(), {"base": "unknown_roborock"}), + (RoborockAccountDoesNotExist(), {"base": "invalid_email"}), + (RoborockInvalidEmail(), {"base": "invalid_email_format"}), + (RoborockUrlException(), {"base": "unknown_url"}), (Exception(), {"base": "unknown"}), ], ) @@ -115,7 +124,8 @@ async def test_config_flow_failures_request_code( "code_login_errors", ), [ - (RoborockException(), {"base": "invalid_code"}), + (RoborockException(), {"base": "unknown_roborock"}), + (RoborockInvalidCode(), {"base": "invalid_code"}), (Exception(), {"base": "unknown"}), ], ) From 89d8bda4bac6dc23d709afa9815417a943ecec8e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 27 Apr 2023 22:00:07 +0200 Subject: [PATCH 030/822] Bump pyTibber to 0.27.2 (#92136) --- 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 d6df026bbd7b..1b6c5e3045ac 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.27.1"] + "requirements": ["pyTibber==0.27.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index d1523565445d..0f212b15b11c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1475,7 +1475,7 @@ pyRFXtrx==0.30.1 pySwitchmate==0.5.1 # homeassistant.components.tibber -pyTibber==0.27.1 +pyTibber==0.27.2 # homeassistant.components.dlink pyW215==0.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6a91880a6d0d..96db981b8d6d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1090,7 +1090,7 @@ pyMetno==0.9.0 pyRFXtrx==0.30.1 # homeassistant.components.tibber -pyTibber==0.27.1 +pyTibber==0.27.2 # homeassistant.components.dlink pyW215==0.7.0 From cdbffee7814380b10952f9ac7f8a1e6bc6e93861 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 27 Apr 2023 22:39:29 +0200 Subject: [PATCH 031/822] Remove legacy pip resolver (#92124) Co-authored-by: J. Nick Koston --- .core_files.yaml | 1 - .github/workflows/builder.yml | 2 +- .github/workflows/ci.yaml | 40 +------------------ .github/workflows/wheels.yml | 4 -- .vscode/tasks.json | 4 +- Dockerfile | 3 -- Dockerfile.dev | 4 +- .../components/mycroft/manifest.json | 1 + homeassistant/components/mycroft/notify.py | 2 +- homeassistant/components/smarty/__init__.py | 2 +- .../components/smarty/binary_sensor.py | 2 +- homeassistant/components/smarty/fan.py | 2 +- homeassistant/components/smarty/manifest.json | 1 + homeassistant/components/smarty/sensor.py | 2 +- .../components/watson_tts/manifest.json | 1 + homeassistant/components/watson_tts/tts.py | 6 ++- requirements_all.txt | 9 ----- script/bootstrap | 4 +- script/pip_check | 28 ------------- script/setup | 2 +- 20 files changed, 22 insertions(+), 98 deletions(-) delete mode 100755 script/pip_check diff --git a/.core_files.yaml b/.core_files.yaml index 7933556b6038..2c9bc22fd704 100644 --- a/.core_files.yaml +++ b/.core_files.yaml @@ -137,7 +137,6 @@ other: &other requirements: &requirements - .github/workflows/** - homeassistant/package_constraints.txt - - script/pip_check - requirements*.txt - pyproject.toml diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 06a95f4cc9be..62270a5424ea 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -140,7 +140,7 @@ jobs: shell: bash run: | python3 -m pip install packaging tomli - python3 -m pip install --use-deprecated=legacy-resolver . + python3 -m pip install . version="$(python3 script/version_bump.py nightly)" if [[ "$(ls home_assistant_frontend*.whl)" =~ ^home_assistant_frontend-(.*)-py3-none-any.whl$ ]]; then diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 30a3f6f4f3eb..583be8f45865 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -544,8 +544,8 @@ jobs: . venv/bin/activate python --version pip install --cache-dir=$PIP_CACHE -U "pip>=21.0,<23.2" setuptools wheel - pip install --cache-dir=$PIP_CACHE -r requirements_all.txt --use-deprecated=legacy-resolver - pip install --cache-dir=$PIP_CACHE -r requirements_test.txt --use-deprecated=legacy-resolver + pip install --cache-dir=$PIP_CACHE -r requirements_all.txt + pip install --cache-dir=$PIP_CACHE -r requirements_test.txt pip install -e . hassfest: @@ -719,42 +719,6 @@ jobs: python --version mypy homeassistant/components/${{ needs.info.outputs.integrations_glob }} - pip-check: - runs-on: ubuntu-22.04 - if: | - github.event.inputs.pylint-only != 'true' - && github.event.inputs.mypy-only != 'true' - needs: - - info - - base - strategy: - fail-fast: false - matrix: - python-version: ${{ fromJson(needs.info.outputs.python_versions) }} - name: Run pip check ${{ matrix.python-version }} - steps: - - name: Check out code from GitHub - uses: actions/checkout@v3.5.2 - - name: Set up Python ${{ matrix.python-version }} - id: python - uses: actions/setup-python@v4.6.0 - with: - python-version: ${{ matrix.python-version }} - check-latest: true - - name: Restore full Python ${{ matrix.python-version }} virtual environment - id: cache-venv - uses: actions/cache/restore@v3.3.1 - with: - path: venv - fail-on-cache-miss: true - key: >- - ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ - needs.info.outputs.python_cache_key }} - - name: Run pip check - run: | - . venv/bin/activate - ./script/pip_check $PIP_CACHE - pytest: runs-on: ubuntu-22.04 if: | diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index b6a00492e3d4..616e25d91624 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -187,7 +187,6 @@ jobs: 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 - legacy: true constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements_all.txtaa" @@ -202,7 +201,6 @@ jobs: 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 - legacy: true constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements_all.txtab" @@ -334,7 +332,6 @@ jobs: 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 - legacy: true constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements_all.txtaa" @@ -349,7 +346,6 @@ jobs: 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 - legacy: true constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements_all.txtab" diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 7af7a426d62b..c484045530d2 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -103,7 +103,7 @@ { "label": "Install all Requirements", "type": "shell", - "command": "pip3 install --use-deprecated=legacy-resolver -r requirements_all.txt", + "command": "pip3 install -r requirements_all.txt", "group": { "kind": "build", "isDefault": true @@ -117,7 +117,7 @@ { "label": "Install all Test Requirements", "type": "shell", - "command": "pip3 install --use-deprecated=legacy-resolver -r requirements_test_all.txt", + "command": "pip3 install -r requirements_test_all.txt", "group": { "kind": "build", "isDefault": true diff --git a/Dockerfile b/Dockerfile index fa8f5520f22b..e229f27cb335 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,7 +18,6 @@ RUN \ --no-index \ --only-binary=:all: \ --find-links "${WHEELS_LINKS}" \ - --use-deprecated=legacy-resolver \ -r homeassistant/requirements.txt COPY requirements_all.txt home_assistant_frontend-* home_assistant_intents-* homeassistant/ @@ -43,7 +42,6 @@ RUN \ --no-index \ --only-binary=:all: \ --find-links "${WHEELS_LINKS}" \ - --use-deprecated=legacy-resolver \ -r homeassistant/requirements_all.txt ## Setup Home Assistant Core @@ -54,7 +52,6 @@ RUN \ --no-index \ --only-binary=:all: \ --find-links "${WHEELS_LINKS}" \ - --use-deprecated=legacy-resolver \ -e ./homeassistant \ && python3 -m compileall \ homeassistant/homeassistant diff --git a/Dockerfile.dev b/Dockerfile.dev index 336648ae1c2b..de49bb77f127 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -45,9 +45,9 @@ WORKDIR /workspaces # Install Python dependencies from requirements COPY requirements.txt ./ COPY homeassistant/package_constraints.txt homeassistant/package_constraints.txt -RUN pip3 install -r requirements.txt --use-deprecated=legacy-resolver +RUN pip3 install -r requirements.txt COPY requirements_test.txt requirements_test_pre_commit.txt ./ -RUN pip3 install -r requirements_test.txt --use-deprecated=legacy-resolver +RUN pip3 install -r requirements_test.txt RUN rm -rf requirements.txt requirements_test.txt requirements_test_pre_commit.txt homeassistant/ # Set the default shell to bash instead of sh diff --git a/homeassistant/components/mycroft/manifest.json b/homeassistant/components/mycroft/manifest.json index 86adf0d48b5e..9b8731f0701b 100644 --- a/homeassistant/components/mycroft/manifest.json +++ b/homeassistant/components/mycroft/manifest.json @@ -2,6 +2,7 @@ "domain": "mycroft", "name": "Mycroft", "codeowners": [], + "disabled": "Dependencies not compatible with the new pip resolver", "documentation": "https://www.home-assistant.io/integrations/mycroft", "iot_class": "local_push", "loggers": ["mycroftapi"], diff --git a/homeassistant/components/mycroft/notify.py b/homeassistant/components/mycroft/notify.py index a9dd82caef1a..172a01017c46 100644 --- a/homeassistant/components/mycroft/notify.py +++ b/homeassistant/components/mycroft/notify.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from mycroftapi import MycroftAPI +from mycroftapi import MycroftAPI # pylint: disable=import-error from homeassistant.components.notify import BaseNotificationService from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/smarty/__init__.py b/homeassistant/components/smarty/__init__.py index e3cf1dcf2870..036fb6e1e90c 100644 --- a/homeassistant/components/smarty/__init__.py +++ b/homeassistant/components/smarty/__init__.py @@ -3,7 +3,7 @@ from datetime import timedelta import ipaddress import logging -from pysmarty import Smarty +from pysmarty import Smarty # pylint: disable=import-error import voluptuous as vol from homeassistant.const import CONF_HOST, CONF_NAME, Platform diff --git a/homeassistant/components/smarty/binary_sensor.py b/homeassistant/components/smarty/binary_sensor.py index d9d757a71b51..baa25115186f 100644 --- a/homeassistant/components/smarty/binary_sensor.py +++ b/homeassistant/components/smarty/binary_sensor.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from pysmarty import Smarty +from pysmarty import Smarty # pylint: disable=import-error from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, diff --git a/homeassistant/components/smarty/fan.py b/homeassistant/components/smarty/fan.py index cf4b49e6105d..cf7db560c153 100644 --- a/homeassistant/components/smarty/fan.py +++ b/homeassistant/components/smarty/fan.py @@ -5,7 +5,7 @@ import logging import math from typing import Any -from pysmarty import Smarty +from pysmarty import Smarty # pylint: disable=import-error from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.core import HomeAssistant, callback diff --git a/homeassistant/components/smarty/manifest.json b/homeassistant/components/smarty/manifest.json index cebb64863a00..8769aa666a78 100644 --- a/homeassistant/components/smarty/manifest.json +++ b/homeassistant/components/smarty/manifest.json @@ -2,6 +2,7 @@ "domain": "smarty", "name": "Salda Smarty", "codeowners": ["@z0mbieprocess"], + "disabled": "Dependencies not compatible with the new pip resolver", "documentation": "https://www.home-assistant.io/integrations/smarty", "iot_class": "local_polling", "loggers": ["pymodbus", "pysmarty"], diff --git a/homeassistant/components/smarty/sensor.py b/homeassistant/components/smarty/sensor.py index 57d681594cf7..df99529b1f46 100644 --- a/homeassistant/components/smarty/sensor.py +++ b/homeassistant/components/smarty/sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations import datetime as dt import logging -from pysmarty import Smarty +from pysmarty import Smarty # pylint: disable=import-error from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.const import UnitOfTemperature diff --git a/homeassistant/components/watson_tts/manifest.json b/homeassistant/components/watson_tts/manifest.json index 2f9404bf146f..f26fc0065613 100644 --- a/homeassistant/components/watson_tts/manifest.json +++ b/homeassistant/components/watson_tts/manifest.json @@ -2,6 +2,7 @@ "domain": "watson_tts", "name": "IBM Watson TTS", "codeowners": ["@rutkai"], + "disabled": "Dependencies not compatible with the new pip resolver", "documentation": "https://www.home-assistant.io/integrations/watson_tts", "iot_class": "cloud_push", "loggers": ["ibm_cloud_sdk_core", "ibm_watson"], diff --git a/homeassistant/components/watson_tts/tts.py b/homeassistant/components/watson_tts/tts.py index f5d643b1336a..08e910feae72 100644 --- a/homeassistant/components/watson_tts/tts.py +++ b/homeassistant/components/watson_tts/tts.py @@ -1,8 +1,10 @@ """Support for IBM Watson TTS integration.""" import logging -from ibm_cloud_sdk_core.authenticators import IAMAuthenticator -from ibm_watson import TextToSpeechV1 +from ibm_cloud_sdk_core.authenticators import ( # pylint: disable=import-error + IAMAuthenticator, +) +from ibm_watson import TextToSpeechV1 # pylint: disable=import-error import voluptuous as vol from homeassistant.components.tts import PLATFORM_SCHEMA, Provider diff --git a/requirements_all.txt b/requirements_all.txt index 0f212b15b11c..c52da3e3f5bc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -949,9 +949,6 @@ iaqualink==0.5.0 # homeassistant.components.ibeacon ibeacon_ble==1.0.1 -# homeassistant.components.watson_tts -ibm-watson==5.2.2 - # homeassistant.components.watson_iot ibmiotf==0.3.4 @@ -1162,9 +1159,6 @@ mutagen==1.46.0 # homeassistant.components.mutesync mutesync==0.0.1 -# homeassistant.components.mycroft -mycroftapi==2.0 - # homeassistant.components.nad nad_receiver==0.3.0 @@ -1977,9 +1971,6 @@ pysmartapp==0.3.3 # homeassistant.components.smartthings pysmartthings==0.7.6 -# homeassistant.components.smarty -pysmarty==0.8 - # homeassistant.components.edl21 pysml==0.0.10 diff --git a/script/bootstrap b/script/bootstrap index 0ea2b1476cda..46a5975eff50 100755 --- a/script/bootstrap +++ b/script/bootstrap @@ -8,5 +8,5 @@ cd "$(dirname "$0")/.." echo "Installing development dependencies..." python3 -m pip install wheel --constraint homeassistant/package_constraints.txt --upgrade -python3 -m pip install colorlog pre-commit $(grep awesomeversion requirements.txt) --constraint homeassistant/package_constraints.txt --use-deprecated=legacy-resolver --upgrade -python3 -m pip install -r requirements_test.txt -c homeassistant/package_constraints.txt --use-deprecated legacy-resolver --upgrade +python3 -m pip install colorlog pre-commit $(grep awesomeversion requirements.txt) --constraint homeassistant/package_constraints.txt --upgrade +python3 -m pip install -r requirements_test.txt -c homeassistant/package_constraints.txt --upgrade diff --git a/script/pip_check b/script/pip_check deleted file mode 100755 index cbbe7ffeeaef..000000000000 --- a/script/pip_check +++ /dev/null @@ -1,28 +0,0 @@ -#!/bin/bash -PIP_CACHE=$1 - -# Number of existing dependency conflicts -# Update if a PR resolves one! -DEPENDENCY_CONFLICTS=3 - -PIP_CHECK=$(pip check --cache-dir=$PIP_CACHE) -LINE_COUNT=$(echo "$PIP_CHECK" | wc -l) -echo "$PIP_CHECK" - -if [[ $((LINE_COUNT)) -gt $DEPENDENCY_CONFLICTS ]] -then - echo "------" - echo "Requirements change added another dependency conflict." - echo "Make sure to check the 'pip check' output above!" - echo "Expected $DEPENDENCY_CONFLICTS conflicts, got $LINE_COUNT." - exit 1 -elif [[ $((LINE_COUNT)) -lt $DEPENDENCY_CONFLICTS ]] -then - echo "------" - echo "It seems like this PR resolves $(( - DEPENDENCY_CONFLICTS - LINE_COUNT)) dependency conflicts." - echo "Please update the 'DEPENDENCY_CONFLICTS' constant " - echo "in 'script/pip_check' to help prevent regressions." - echo "Update it to: $((LINE_COUNT))" - exit 1 -fi diff --git a/script/setup b/script/setup index 782eb5106546..e68ec72cbbae 100755 --- a/script/setup +++ b/script/setup @@ -24,7 +24,7 @@ fi script/bootstrap pre-commit install -python3 -m pip install -e . --constraint homeassistant/package_constraints.txt --use-deprecated=legacy-resolver +python3 -m pip install -e . --constraint homeassistant/package_constraints.txt python3 -m script.translations develop --all hass --script ensure_config -c config From 9d0dd0b7843f6d6058a220be36e7cf71a16bf26f Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Thu, 27 Apr 2023 18:35:07 -0400 Subject: [PATCH 032/822] Fix ZHA startup failure with the Konke button (#92144) * Ensure devices with bad cluster subclasses do not prevent startup * Explicitly unit test an affected SML001 device * Do not use invalid `hue_occupancy` attribute name * Actually remove `hue_occupancy` * Bump ZHA dependencies --- homeassistant/components/zha/binary_sensor.py | 6 + .../zha/core/cluster_handlers/general.py | 11 +- homeassistant/components/zha/core/const.py | 1 + homeassistant/components/zha/manifest.json | 4 +- homeassistant/components/zha/select.py | 6 +- requirements_all.txt | 4 +- requirements_test_all.txt | 4 +- tests/components/zha/conftest.py | 9 + tests/components/zha/zha_devices_list.py | 164 +++++++++++++++++- 9 files changed, 198 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index 6b080db081e2..1c29f6197192 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -22,6 +22,7 @@ from .core import discovery from .core.const import ( CLUSTER_HANDLER_ACCELEROMETER, CLUSTER_HANDLER_BINARY_INPUT, + CLUSTER_HANDLER_HUE_OCCUPANCY, CLUSTER_HANDLER_OCCUPANCY, CLUSTER_HANDLER_ON_OFF, CLUSTER_HANDLER_ZONE, @@ -130,6 +131,11 @@ class Occupancy(BinarySensor): _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.OCCUPANCY +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_HUE_OCCUPANCY) +class HueOccupancy(Occupancy): + """ZHA Hue occupancy.""" + + @STRICT_MATCH(cluster_handler_names=CLUSTER_HANDLER_ON_OFF) class Opening(BinarySensor): """ZHA OnOff BinarySensor.""" diff --git a/homeassistant/components/zha/core/cluster_handlers/general.py b/homeassistant/components/zha/core/cluster_handlers/general.py index 12b1f6368668..d4014bbf697f 100644 --- a/homeassistant/components/zha/core/cluster_handlers/general.py +++ b/homeassistant/components/zha/core/cluster_handlers/general.py @@ -347,7 +347,7 @@ class OnOffClientClusterHandler(ClientClusterHandler): class OnOffClusterHandler(ClusterHandler): """Cluster handler for the OnOff Zigbee cluster.""" - ON_OFF = 0 + ON_OFF = general.OnOff.attributes_by_name["on_off"].id REPORT_CONFIG = (AttrReportConfig(attr="on_off", config=REPORT_CONFIG_IMMEDIATE),) ZCL_INIT_ATTRS = { "start_up_on_off": True, @@ -374,6 +374,15 @@ class OnOffClusterHandler(ClusterHandler): if self.cluster.endpoint.model == "TS011F": self.ZCL_INIT_ATTRS["child_lock"] = True + @classmethod + def matches(cls, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> bool: + """Filter the cluster match for specific devices.""" + return not ( + cluster.endpoint.device.manufacturer == "Konke" + and cluster.endpoint.device.model + in ("3AFE280100510001", "3AFE170100510001") + ) + @property def on_off(self) -> bool | None: """Return cached value of on/off attribute.""" diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index ad4bfd7a6906..c90c78243d1a 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -78,6 +78,7 @@ CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT = "electrical_measurement" CLUSTER_HANDLER_EVENT_RELAY = "event_relay" CLUSTER_HANDLER_FAN = "fan" CLUSTER_HANDLER_HUMIDITY = "humidity" +CLUSTER_HANDLER_HUE_OCCUPANCY = "philips_occupancy" CLUSTER_HANDLER_SOIL_MOISTURE = "soil_moisture" CLUSTER_HANDLER_LEAF_WETNESS = "leaf_wetness" CLUSTER_HANDLER_IAS_ACE = "ias_ace" diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 2e08b0cc6d87..2063a9906ec4 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -20,10 +20,10 @@ "zigpy_znp" ], "requirements": [ - "bellows==0.35.1", + "bellows==0.35.2", "pyserial==3.5", "pyserial-asyncio==0.6", - "zha-quirks==0.0.97", + "zha-quirks==0.0.98", "zigpy-deconz==0.21.0", "zigpy==0.55.0", "zigpy-xbee==0.18.0", diff --git a/homeassistant/components/zha/select.py b/homeassistant/components/zha/select.py index 1bab8a3f2c3f..2453f40af446 100644 --- a/homeassistant/components/zha/select.py +++ b/homeassistant/components/zha/select.py @@ -20,9 +20,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .core import discovery from .core.const import ( + CLUSTER_HANDLER_HUE_OCCUPANCY, CLUSTER_HANDLER_IAS_WD, CLUSTER_HANDLER_INOVELLI, - CLUSTER_HANDLER_OCCUPANCY, CLUSTER_HANDLER_ON_OFF, DATA_ZHA, SIGNAL_ADD_ENTITIES, @@ -367,7 +367,7 @@ class HueV1MotionSensitivities(types.enum8): @CONFIG_DIAGNOSTIC_MATCH( - cluster_handler_names=CLUSTER_HANDLER_OCCUPANCY, + cluster_handler_names=CLUSTER_HANDLER_HUE_OCCUPANCY, manufacturers={"Philips", "Signify Netherlands B.V."}, models={"SML001"}, ) @@ -390,7 +390,7 @@ class HueV2MotionSensitivities(types.enum8): @CONFIG_DIAGNOSTIC_MATCH( - cluster_handler_names=CLUSTER_HANDLER_OCCUPANCY, + cluster_handler_names=CLUSTER_HANDLER_HUE_OCCUPANCY, manufacturers={"Philips", "Signify Netherlands B.V."}, models={"SML002", "SML003", "SML004"}, ) diff --git a/requirements_all.txt b/requirements_all.txt index c52da3e3f5bc..f2321b4e2519 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -428,7 +428,7 @@ beautifulsoup4==4.11.1 # beewi_smartclim==0.0.10 # homeassistant.components.zha -bellows==0.35.1 +bellows==0.35.2 # homeassistant.components.bmw_connected_drive bimmer_connected==0.13.0 @@ -2709,7 +2709,7 @@ zeroconf==0.58.2 zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.97 +zha-quirks==0.0.98 # homeassistant.components.zhong_hong zhong_hong_hvac==1.0.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 96db981b8d6d..8fb969ec23a8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -361,7 +361,7 @@ base36==0.1.1 beautifulsoup4==4.11.1 # homeassistant.components.zha -bellows==0.35.1 +bellows==0.35.2 # homeassistant.components.bmw_connected_drive bimmer_connected==0.13.0 @@ -1964,7 +1964,7 @@ zeroconf==0.58.2 zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.97 +zha-quirks==0.0.98 # homeassistant.components.zha zigpy-deconz==0.21.0 diff --git a/tests/components/zha/conftest.py b/tests/components/zha/conftest.py index 0621c6521a91..271108496b21 100644 --- a/tests/components/zha/conftest.py +++ b/tests/components/zha/conftest.py @@ -240,6 +240,15 @@ def zigpy_device_mock(zigpy_app_controller): ): common.patch_cluster(cluster) + if attributes is not None: + for ep_id, clusters in attributes.items(): + for cluster_name, attrs in clusters.items(): + cluster = getattr(device.endpoints[ep_id], cluster_name) + + for name, value in attrs.items(): + attr_id = cluster.find_attribute(name).id + cluster._attr_cache[attr_id] = value + return device return _mock_dev diff --git a/tests/components/zha/zha_devices_list.py b/tests/components/zha/zha_devices_list.py index 0ec1ae8aa141..a45d02a3aa26 100644 --- a/tests/components/zha/zha_devices_list.py +++ b/tests/components/zha/zha_devices_list.py @@ -10,16 +10,26 @@ from zigpy.const import ( SIG_MODEL, SIG_NODE_DESC, ) -from zigpy.profiles import zha +from zigpy.profiles import zha, zll +from zigpy.types import Bool, uint8_t from zigpy.zcl.clusters.closures import DoorLock from zigpy.zcl.clusters.general import ( Basic, Groups, Identify, + LevelControl, MultistateInput, + OnOff, Ota, + PowerConfiguration, Scenes, ) +from zigpy.zcl.clusters.lighting import Color +from zigpy.zcl.clusters.measurement import ( + IlluminanceMeasurement, + OccupancySensing, + TemperatureMeasurement, +) DEV_SIG_CLUSTER_HANDLERS = "cluster_handlers" DEV_SIG_DEV_NO = "device_no" @@ -5373,4 +5383,156 @@ DEVICES = [ }, }, }, + { + DEV_SIG_DEV_NO: 100, + SIG_MANUFACTURER: "Konke", + SIG_MODEL: "3AFE170100510001", + SIG_NODE_DESC: b"\x02@\x80\x02\x10RR\x00\x00,R\x00\x00", + SIG_ENDPOINTS: { + 1: { + PROFILE_ID: 260, + DEVICE_TYPE: zha.DeviceType.ON_OFF_OUTPUT, + INPUT_CLUSTERS: [ + Basic.cluster_id, + PowerConfiguration.cluster_id, + Identify.cluster_id, + Groups.cluster_id, + Scenes.cluster_id, + OnOff.cluster_id, + ], + OUTPUT_CLUSTERS: [ + Identify.cluster_id, + ], + } + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: [], + DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.konke_3afe170100510001_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.konke_3afe170100510001_battery", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.konke_3afe170100510001_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.konke_3afe170100510001_lqi", + }, + }, + }, + { + DEV_SIG_DEV_NO: 101, + SIG_MANUFACTURER: "Philips", + SIG_MODEL: "SML001", + SIG_NODE_DESC: b"\x02@\x80\x0b\x10Y?\x00\x00\x00?\x00\x00", + SIG_ENDPOINTS: { + 1: { + PROFILE_ID: zll.PROFILE_ID, + DEVICE_TYPE: zll.DeviceType.ON_OFF_SENSOR, + INPUT_CLUSTERS: [Basic.cluster_id], + OUTPUT_CLUSTERS: [ + Basic.cluster_id, + Identify.cluster_id, + Groups.cluster_id, + Scenes.cluster_id, + OnOff.cluster_id, + LevelControl.cluster_id, + Color.cluster_id, + ], + }, + 2: { + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: zha.DeviceType.OCCUPANCY_SENSOR, + INPUT_CLUSTERS: [ + Basic.cluster_id, + PowerConfiguration.cluster_id, + Identify.cluster_id, + IlluminanceMeasurement.cluster_id, + TemperatureMeasurement.cluster_id, + OccupancySensing.cluster_id, + ], + OUTPUT_CLUSTERS: [ + Ota.cluster_id, + ], + }, + }, + DEV_SIG_ATTRIBUTES: { + 2: { + "basic": { + "trigger_indicator": Bool(False), + }, + "philips_occupancy": { + "sensitivity": uint8_t(1), + }, + } + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: [ + "1:0x0005", + "1:0x0006", + "1:0x0008", + "1:0x0300", + "2:0x0019", + ], + DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-2-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.philips_sml001_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-2-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.philips_sml001_battery", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.philips_sml001_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.philips_sml001_lqi", + }, + ("binary_sensor", "00:11:22:33:44:55:66:77-1-6"): { + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Motion", + DEV_SIG_ENT_MAP_ID: "binary_sensor.philips_sml001_motion", + }, + ("sensor", "00:11:22:33:44:55:66:77-2-1024"): { + DEV_SIG_CLUSTER_HANDLERS: ["illuminance"], + DEV_SIG_ENT_MAP_CLASS: "Illuminance", + DEV_SIG_ENT_MAP_ID: "sensor.philips_sml001_illuminance", + }, + ("binary_sensor", "00:11:22:33:44:55:66:77-2-1030"): { + DEV_SIG_CLUSTER_HANDLERS: ["philips_occupancy"], + DEV_SIG_ENT_MAP_CLASS: "HueOccupancy", + DEV_SIG_ENT_MAP_ID: "binary_sensor.philips_sml001_occupancy", + }, + ("sensor", "00:11:22:33:44:55:66:77-2-1026"): { + DEV_SIG_CLUSTER_HANDLERS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.philips_sml001_temperature", + }, + ("switch", "00:11:22:33:44:55:66:77-2-0-trigger_indicator"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "HueMotionTriggerIndicatorSwitch", + DEV_SIG_ENT_MAP_ID: "switch.philips_sml001_led_trigger_indicator", + }, + ("select", "00:11:22:33:44:55:66:77-2-1030-motion_sensitivity"): { + DEV_SIG_CLUSTER_HANDLERS: ["philips_occupancy"], + DEV_SIG_ENT_MAP_CLASS: "HueV1MotionSensitivity", + DEV_SIG_ENT_MAP_ID: "select.philips_sml001_hue_motion_sensitivity", + }, + }, + }, ] From 4ce1106243e10eceacd0e3bfaf156c044c5d51a3 Mon Sep 17 00:00:00 2001 From: jjlawren Date: Thu, 27 Apr 2023 22:51:51 -0500 Subject: [PATCH 033/822] Properly resolve `media_source` URLs for Sonos announcements (#92154) Properly resolve media_source URLs for Sonos announcements --- .../components/sonos/media_player.py | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 7ef103e4d047..7e6c210a1646 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -506,13 +506,23 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): If media_type is "playlist", media_id should be a Sonos Playlist name. Otherwise, media_id should be a URI. """ + is_radio = False + + if media_source.is_media_source_id(media_id): + is_radio = media_id.startswith("media-source://radio_browser/") + media_type = MediaType.MUSIC + media = await media_source.async_resolve_media( + self.hass, media_id, self.entity_id + ) + media_id = async_process_play_media_url(self.hass, media.url) + if kwargs.get(ATTR_MEDIA_ANNOUNCE): volume = kwargs.get("extra", {}).get("volume") _LOGGER.debug("Playing %s using websocket audioclip", media_id) try: assert self.speaker.websocket response, _ = await self.speaker.websocket.play_clip( - media_id, + async_process_play_media_url(self.hass, media_id), volume=volume, ) except SonosWebsocketError as exc: @@ -526,16 +536,6 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): media_type = spotify.resolve_spotify_media_type(media_type) media_id = spotify.spotify_uri_from_media_browser_url(media_id) - is_radio = False - - if media_source.is_media_source_id(media_id): - is_radio = media_id.startswith("media-source://radio_browser/") - media_type = MediaType.MUSIC - media = await media_source.async_resolve_media( - self.hass, media_id, self.entity_id - ) - media_id = media.url - await self.hass.async_add_executor_job( partial(self._play_media, media_type, media_id, is_radio, **kwargs) ) From d648a93dcb65f0384360496d3ea106da5887112e Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 28 Apr 2023 09:44:23 +0200 Subject: [PATCH 034/822] Fix options flow Workday (#92140) * Fix options flow workday * simpler --- homeassistant/components/workday/config_flow.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/workday/config_flow.py b/homeassistant/components/workday/config_flow.py index 65180d86d7c3..be11b0b034d8 100644 --- a/homeassistant/components/workday/config_flow.py +++ b/homeassistant/components/workday/config_flow.py @@ -281,13 +281,13 @@ class WorkdayOptionsFlowHandler(OptionsFlowWithConfigEntry): else: return self.async_create_entry(data=combined_input) - saved_options = self.options.copy() - if saved_options[CONF_PROVINCE] is None: - saved_options[CONF_PROVINCE] = NONE_SENTINEL schema: vol.Schema = await self.hass.async_add_executor_job( add_province_to_schema, DATA_SCHEMA_OPT, self.options ) - new_schema = self.add_suggested_values_to_schema(schema, user_input) + + new_schema = self.add_suggested_values_to_schema( + schema, user_input or self.options + ) return self.async_show_form( step_id="init", From a6a485e59fe250dcd47325dcd0b7e8ba025f7c56 Mon Sep 17 00:00:00 2001 From: Luke Date: Fri, 28 Apr 2023 03:49:35 -0400 Subject: [PATCH 035/822] Bump Roborock to 0.8.3 (#92151) --- 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 f433aee25c48..7cb686f18519 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.8.1"] + "requirements": ["python-roborock==0.8.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index f2321b4e2519..b3f4dfa8b758 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2099,7 +2099,7 @@ python-qbittorrent==0.4.2 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==0.8.1 +python-roborock==0.8.3 # homeassistant.components.smarttub python-smarttub==0.0.33 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8fb969ec23a8..96ed97a817e7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1516,7 +1516,7 @@ python-picnic-api==1.1.0 python-qbittorrent==0.4.2 # homeassistant.components.roborock -python-roborock==0.8.1 +python-roborock==0.8.3 # homeassistant.components.smarttub python-smarttub==0.0.33 From 3a95a1fd8a0e1289818bb5593c48f151ecc19147 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 28 Apr 2023 09:52:20 +0200 Subject: [PATCH 036/822] Fix ignored apple tvs being scanned over and over (#92150) --- .../components/apple_tv/config_flow.py | 25 +++++++--- tests/components/apple_tv/test_config_flow.py | 46 +++++++++++++++++++ 2 files changed, 64 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/apple_tv/config_flow.py b/homeassistant/components/apple_tv/config_flow.py index d000c0346afc..9b80d992cdd9 100644 --- a/homeassistant/components/apple_tv/config_flow.py +++ b/homeassistant/components/apple_tv/config_flow.py @@ -324,18 +324,29 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): all_identifiers = set(self.atv.all_identifiers) discovered_ip_address = str(self.atv.address) for entry in self._async_current_entries(): - if not all_identifiers.intersection( + existing_identifiers = set( entry.data.get(CONF_IDENTIFIERS, [entry.unique_id]) - ): + ) + if not all_identifiers.intersection(existing_identifiers): continue - if entry.data.get(CONF_ADDRESS) != discovered_ip_address: + combined_identifiers = existing_identifiers | all_identifiers + if entry.data.get( + CONF_ADDRESS + ) != discovered_ip_address or combined_identifiers != set( + entry.data.get(CONF_IDENTIFIERS, []) + ): self.hass.config_entries.async_update_entry( entry, - data={**entry.data, CONF_ADDRESS: discovered_ip_address}, - ) - self.hass.async_create_task( - self.hass.config_entries.async_reload(entry.entry_id) + data={ + **entry.data, + CONF_ADDRESS: discovered_ip_address, + CONF_IDENTIFIERS: list(combined_identifiers), + }, ) + if entry.source != config_entries.SOURCE_IGNORE: + self.hass.async_create_task( + self.hass.config_entries.async_reload(entry.entry_id) + ) if not allow_exist: raise DeviceAlreadyConfigured() diff --git a/tests/components/apple_tv/test_config_flow.py b/tests/components/apple_tv/test_config_flow.py index 52388e694f57..6256d1dde9ca 100644 --- a/tests/components/apple_tv/test_config_flow.py +++ b/tests/components/apple_tv/test_config_flow.py @@ -730,6 +730,52 @@ async def test_zeroconf_ip_change_via_secondary_identifier( assert len(mock_async_setup.mock_calls) == 2 assert entry.data[CONF_ADDRESS] == "127.0.0.1" assert unrelated_entry.data[CONF_ADDRESS] == "127.0.0.2" + assert set(entry.data[CONF_IDENTIFIERS]) == {"airplayid", "mrpid"} + + +async def test_zeroconf_updates_identifiers_for_ignored_entries( + hass: HomeAssistant, mock_scan +) -> None: + """Test that an ignored config entry gets updated when the ip changes. + + Instead of checking only the unique id, all the identifiers + in the config entry are checked + """ + entry = MockConfigEntry( + domain="apple_tv", + unique_id="aa:bb:cc:dd:ee:ff", + source=config_entries.SOURCE_IGNORE, + data={CONF_IDENTIFIERS: ["mrpid"], CONF_ADDRESS: "127.0.0.2"}, + ) + unrelated_entry = MockConfigEntry( + domain="apple_tv", unique_id="unrelated", data={CONF_ADDRESS: "127.0.0.2"} + ) + unrelated_entry.add_to_hass(hass) + entry.add_to_hass(hass) + mock_scan.result = [ + create_conf( + IPv4Address("127.0.0.1"), "Device", mrp_service(), airplay_service() + ) + ] + + with patch( + "homeassistant.components.apple_tv.async_setup_entry", return_value=True + ) as mock_async_setup: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=DMAP_SERVICE, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert ( + len(mock_async_setup.mock_calls) == 0 + ) # Should not be called because entry is ignored + assert entry.data[CONF_ADDRESS] == "127.0.0.1" + assert unrelated_entry.data[CONF_ADDRESS] == "127.0.0.2" + assert set(entry.data[CONF_IDENTIFIERS]) == {"airplayid", "mrpid"} async def test_zeroconf_add_existing_aborts(hass: HomeAssistant, dmap_device) -> None: From 0411b384ddfdffba862aeac0bd41d5cdf6e1d828 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 28 Apr 2023 09:53:04 +0200 Subject: [PATCH 037/822] Fix release build for armhf, exclude pandas (#92159) --- .github/workflows/builder.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 62270a5424ea..94d4128bc660 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -174,6 +174,18 @@ jobs: python -m script.gen_requirements_all fi + - name: Adjustments for armhf + if: matrix.arch == 'armhf' + run: | + # Pandas has issues building on armhf, it is expected they + # will drop the platform in the near future (they consider it + # "flimsy" on 386). The following packages depend on pandas, + # so we comment them out. + sed -i "s|env_canada|# env_canada|g" requirements_all.txt + sed -i "s|noaa-coops|# noaa-coops|g" requirements_all.txt + sed -i "s|pyezviz|# pyezviz|g" requirements_all.txt + sed -i "s|pykrakenapi|# pykrakenapi|g" requirements_all.txt + - name: Download Translations run: python3 -m script.translations download env: From cad03101939914c00aba7f28a07bba7502d829f0 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Fri, 28 Apr 2023 10:41:21 +0200 Subject: [PATCH 038/822] Fix lingering timer in Axis library (#92143) --- homeassistant/components/axis/device.py | 2 +- homeassistant/components/axis/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/axis/device.py b/homeassistant/components/axis/device.py index 1a65b31ef728..f53e69fba9fc 100644 --- a/homeassistant/components/axis/device.py +++ b/homeassistant/components/axis/device.py @@ -218,7 +218,7 @@ class AxisNetworkDevice: """Stop stream.""" if self.api.stream.state != State.STOPPED: self.api.stream.connection_status_callback.clear() - self.api.stream.stop() + self.api.stream.stop() async def shutdown(self, event) -> None: """Stop the event stream.""" diff --git a/homeassistant/components/axis/manifest.json b/homeassistant/components/axis/manifest.json index b8cd005a2644..296a3da8b663 100644 --- a/homeassistant/components/axis/manifest.json +++ b/homeassistant/components/axis/manifest.json @@ -26,7 +26,7 @@ "iot_class": "local_push", "loggers": ["axis"], "quality_scale": "platinum", - "requirements": ["axis==47"], + "requirements": ["axis==48"], "ssdp": [ { "manufacturer": "AXIS" diff --git a/requirements_all.txt b/requirements_all.txt index b3f4dfa8b758..e01392ac417a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -401,7 +401,7 @@ aurorapy==0.2.7 # avion==0.10 # homeassistant.components.axis -axis==47 +axis==48 # homeassistant.components.azure_event_hub azure-eventhub==5.11.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 96ed97a817e7..5f984a781358 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -349,7 +349,7 @@ auroranoaa==0.0.3 aurorapy==0.2.7 # homeassistant.components.axis -axis==47 +axis==48 # homeassistant.components.azure_event_hub azure-eventhub==5.11.1 From 6b829ca57bbbf1b605dfd4ae600a0b3d7ca1b6dc Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Fri, 28 Apr 2023 05:31:16 -0400 Subject: [PATCH 039/822] Bump pyvizio to 0.1.61 (#92161) --- homeassistant/components/vizio/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/vizio/manifest.json b/homeassistant/components/vizio/manifest.json index 999ef2690358..e6812ed58b10 100644 --- a/homeassistant/components/vizio/manifest.json +++ b/homeassistant/components/vizio/manifest.json @@ -8,6 +8,6 @@ "iot_class": "local_polling", "loggers": ["pyvizio"], "quality_scale": "platinum", - "requirements": ["pyvizio==0.1.60"], + "requirements": ["pyvizio==0.1.61"], "zeroconf": ["_viziocast._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index e01392ac417a..0797510200cf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2167,7 +2167,7 @@ pyversasense==0.0.6 pyvesync==2.1.1 # homeassistant.components.vizio -pyvizio==0.1.60 +pyvizio==0.1.61 # homeassistant.components.velux pyvlx==0.2.20 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5f984a781358..4ca7e9e64faa 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1566,7 +1566,7 @@ pyvera==0.3.13 pyvesync==2.1.1 # homeassistant.components.vizio -pyvizio==0.1.60 +pyvizio==0.1.61 # homeassistant.components.volumio pyvolumio==0.1.5 From ec1ae7e2df2496d5ca0ef75d73e5cfbf76b6f25f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 28 Apr 2023 14:27:06 +0200 Subject: [PATCH 040/822] Remove homeassistant from cloud dependencies (#92169) --- homeassistant/components/cloud/manifest.json | 2 +- tests/components/cloud/__init__.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 2dbbc81e4c6f..45d540e9780a 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Cloud", "after_dependencies": ["assist_pipeline", "google_assistant", "alexa"], "codeowners": ["@home-assistant/cloud"], - "dependencies": ["homeassistant", "http", "webhook"], + "dependencies": ["http", "webhook"], "documentation": "https://www.home-assistant.io/integrations/cloud", "integration_type": "system", "iot_class": "cloud_push", diff --git a/tests/components/cloud/__init__.py b/tests/components/cloud/__init__.py index 7933d8639c18..ea8c09706c51 100644 --- a/tests/components/cloud/__init__.py +++ b/tests/components/cloud/__init__.py @@ -9,6 +9,10 @@ from homeassistant.setup import async_setup_component async def mock_cloud(hass, config=None): """Mock cloud.""" + # The homeassistant integration is needed by cloud. It's not in it's requirements + # because it's always setup by bootstrap. Set it up manually in tests. + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, cloud.DOMAIN, {"cloud": config or {}}) cloud_inst = hass.data["cloud"] with patch("hass_nabucasa.Cloud.run_executor", AsyncMock(return_value=None)): From c3d3bc4b74e2e2cce7fb745736ba12ccdc8bfc58 Mon Sep 17 00:00:00 2001 From: Nolan Gilley Date: Fri, 28 Apr 2023 08:30:10 -0400 Subject: [PATCH 041/822] Upgrade lakeside to 0.13 (#92173) --- homeassistant/components/eufy/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/eufy/manifest.json b/homeassistant/components/eufy/manifest.json index 5232fadc4284..ccf15144f9e5 100644 --- a/homeassistant/components/eufy/manifest.json +++ b/homeassistant/components/eufy/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/eufy", "iot_class": "local_polling", "loggers": ["lakeside"], - "requirements": ["lakeside==0.12"] + "requirements": ["lakeside==0.13"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0797510200cf..ec03f6db4fe4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1031,7 +1031,7 @@ krakenex==2.1.0 lacrosse-view==0.0.9 # homeassistant.components.eufy -lakeside==0.12 +lakeside==0.13 # homeassistant.components.laundrify laundrify_aio==1.1.2 From 2bfa52106823a6bc639a0ff679dc1c7e624ca26b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 28 Apr 2023 15:38:32 +0200 Subject: [PATCH 042/822] Fix hassio with python3.11 (#92178) --- homeassistant/components/hassio/addon_panel.py | 6 +++++- homeassistant/components/hassio/ingress.py | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hassio/addon_panel.py b/homeassistant/components/hassio/addon_panel.py index d469b66b9e0e..b2cf0040be04 100644 --- a/homeassistant/components/hassio/addon_panel.py +++ b/homeassistant/components/hassio/addon_panel.py @@ -30,7 +30,11 @@ async def async_setup_addon_panel(hass: HomeAssistant, hassio): for addon, data in panels.items(): if not data[ATTR_ENABLE]: continue - jobs.append(_register_panel(hass, addon, data)) + jobs.append( + asyncio.create_task( + _register_panel(hass, addon, data), name=f"register panel {addon}" + ) + ) if jobs: await asyncio.wait(jobs) diff --git a/homeassistant/components/hassio/ingress.py b/homeassistant/components/hassio/ingress.py index 334c7cf719cc..fc92e9309a03 100644 --- a/homeassistant/components/hassio/ingress.py +++ b/homeassistant/components/hassio/ingress.py @@ -119,8 +119,8 @@ class HassIOIngress(HomeAssistantView): # Proxy requests await asyncio.wait( [ - _websocket_forward(ws_server, ws_client), - _websocket_forward(ws_client, ws_server), + asyncio.create_task(_websocket_forward(ws_server, ws_client)), + asyncio.create_task(_websocket_forward(ws_client, ws_server)), ], return_when=asyncio.FIRST_COMPLETED, ) From ebd9cd096a454874841a04d2a9a421b6169a71d0 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 28 Apr 2023 15:59:21 +0200 Subject: [PATCH 043/822] Keep expose setting in sync for assist (#92158) * Keep expose setting in sync for assist * Fix initialization, add test * Fix tests * Add AgentManager.async_setup * Fix typo --------- Co-authored-by: Martin Hjelmare --- .../components/conversation/__init__.py | 14 +++++--- .../components/conversation/default_agent.py | 24 +++++++++++-- .../conversation/test_default_agent.py | 34 +++++++++++++++++++ tests/components/mobile_app/test_webhook.py | 9 ++++- 4 files changed, 74 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index 2796e51c27de..f156acfd568c 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -23,7 +23,7 @@ from homeassistant.util import language as language_util from .agent import AbstractConversationAgent, ConversationInput, ConversationResult from .const import HOME_ASSISTANT_AGENT -from .default_agent import DefaultAgent +from .default_agent import DefaultAgent, async_setup as async_setup_default_agent __all__ = [ "DOMAIN", @@ -93,7 +93,9 @@ CONFIG_SCHEMA = vol.Schema( @core.callback def _get_agent_manager(hass: HomeAssistant) -> AgentManager: """Get the active agent.""" - return AgentManager(hass) + manager = AgentManager(hass) + manager.async_setup() + return manager @core.callback @@ -389,7 +391,11 @@ class AgentManager: """Initialize the conversation agents.""" self.hass = hass self._agents: dict[str, AbstractConversationAgent] = {} - self._default_agent_init_lock = asyncio.Lock() + self._builtin_agent_init_lock = asyncio.Lock() + + def async_setup(self) -> None: + """Set up the conversation agents.""" + async_setup_default_agent(self.hass) async def async_get_agent( self, agent_id: str | None = None @@ -402,7 +408,7 @@ class AgentManager: if self._builtin_agent is not None: return self._builtin_agent - async with self._default_agent_init_lock: + async with self._builtin_agent_init_lock: if self._builtin_agent is not None: return self._builtin_agent diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index b3a66d803079..d347140af2e2 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -73,6 +73,26 @@ def _get_language_variations(language: str) -> Iterable[str]: yield lang +@core.callback +def async_setup(hass: core.HomeAssistant) -> None: + """Set up entity registry listener for the default agent.""" + entity_registry = er.async_get(hass) + for entity_id in entity_registry.entities: + async_should_expose(hass, DOMAIN, entity_id) + + @core.callback + def async_handle_entity_registry_changed(event: core.Event) -> None: + """Set expose flag on newly created entities.""" + if event.data["action"] == "create": + async_should_expose(hass, DOMAIN, event.data["entity_id"]) + + hass.bus.async_listen( + er.EVENT_ENTITY_REGISTRY_UPDATED, + async_handle_entity_registry_changed, + run_immediately=True, + ) + + class DefaultAgent(AbstractConversationAgent): """Default agent for conversation agent.""" @@ -472,10 +492,10 @@ class DefaultAgent(AbstractConversationAgent): return self._slot_lists area_ids_with_entities: set[str] = set() - all_entities = er.async_get(self.hass) + entity_registry = er.async_get(self.hass) entities = [ entity - for entity in all_entities.entities.values() + for entity in entity_registry.entities.values() if async_should_expose(self.hass, DOMAIN, entity.entity_id) ] devices = dr.async_get(self.hass) diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index 810e29050344..44bb31119879 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -4,6 +4,9 @@ from unittest.mock import patch import pytest from homeassistant.components import conversation +from homeassistant.components.homeassistant.exposed_entities import ( + async_get_assistant_settings, +) from homeassistant.const import ATTR_FRIENDLY_NAME from homeassistant.core import DOMAIN as HASS_DOMAIN, Context, HomeAssistant from homeassistant.helpers import ( @@ -137,3 +140,34 @@ async def test_conversation_agent( return_value={"homeassistant": ["dwarvish", "elvish", "entish"]}, ): assert agent.supported_languages == ["dwarvish", "elvish", "entish"] + + +async def test_expose_flag_automatically_set( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test DefaultAgent sets the expose flag on all entities automatically.""" + assert await async_setup_component(hass, "homeassistant", {}) + + light = entity_registry.async_get_or_create("light", "demo", "1234") + test = entity_registry.async_get_or_create("test", "demo", "1234") + + assert async_get_assistant_settings(hass, conversation.DOMAIN) == {} + + assert await async_setup_component(hass, "conversation", {}) + await hass.async_block_till_done() + + # After setting up conversation, the expose flag should now be set on all entities + assert async_get_assistant_settings(hass, conversation.DOMAIN) == { + light.entity_id: {"should_expose": True}, + test.entity_id: {"should_expose": False}, + } + + # New entities will automatically have the expose flag set + new_light = entity_registry.async_get_or_create("light", "demo", "2345") + await hass.async_block_till_done() + assert async_get_assistant_settings(hass, conversation.DOMAIN) == { + light.entity_id: {"should_expose": True}, + new_light.entity_id: {"should_expose": True}, + test.entity_id: {"should_expose": False}, + } diff --git a/tests/components/mobile_app/test_webhook.py b/tests/components/mobile_app/test_webhook.py index 65e21c2718ed..02c9ace7cd47 100644 --- a/tests/components/mobile_app/test_webhook.py +++ b/tests/components/mobile_app/test_webhook.py @@ -18,6 +18,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.setup import async_setup_component from .const import CALL_SERVICE, FIRE_EVENT, REGISTER_CLEARTEXT, RENDER_TEMPLATE, UPDATE @@ -28,6 +29,12 @@ from tests.components.conversation.conftest import mock_agent mock_agent = mock_agent +@pytest.fixture +async def homeassistant(hass): + """Load the homeassistant integration.""" + await async_setup_component(hass, "homeassistant", {}) + + def encrypt_payload(secret_key, payload, encode_json=True): """Return a encrypted payload given a key and dictionary of data.""" try: @@ -1014,7 +1021,7 @@ async def test_reregister_sensor( async def test_webhook_handle_conversation_process( - hass: HomeAssistant, create_registrations, webhook_client, mock_agent + hass: HomeAssistant, homeassistant, create_registrations, webhook_client, mock_agent ) -> None: """Test that we can converse.""" webhook_client.server.app.router._frozen = False From e6438dabff67f468e3d95da12b2f3660eb19a1a8 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Fri, 28 Apr 2023 11:29:24 -0400 Subject: [PATCH 044/822] Fix ZHA device triggers (#92186) * Fix missing endpoint data on ZHA events * revert to flat structure * update test --- .../components/zha/core/cluster_handlers/__init__.py | 4 ++-- homeassistant/components/zha/core/endpoint.py | 12 +++++++----- tests/components/zha/test_device_trigger.py | 3 ++- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/zha/core/cluster_handlers/__init__.py b/homeassistant/components/zha/core/cluster_handlers/__init__.py index b3ec6b828eee..7863b0434555 100644 --- a/homeassistant/components/zha/core/cluster_handlers/__init__.py +++ b/homeassistant/components/zha/core/cluster_handlers/__init__.py @@ -424,13 +424,13 @@ class ClusterHandler(LogMixin): else: raise TypeError(f"Unexpected zha_send_event {command!r} argument: {arg!r}") - self._endpoint.device.zha_send_event( + self._endpoint.send_event( { ATTR_UNIQUE_ID: self.unique_id, ATTR_CLUSTER_ID: self.cluster.cluster_id, ATTR_COMMAND: command, # Maintain backwards compatibility with the old zigpy response format - ATTR_ARGS: args, # type: ignore[dict-item] + ATTR_ARGS: args, ATTR_PARAMS: params, } ) diff --git a/homeassistant/components/zha/core/endpoint.py b/homeassistant/components/zha/core/endpoint.py index c0a727414b6f..d134c033ed77 100644 --- a/homeassistant/components/zha/core/endpoint.py +++ b/homeassistant/components/zha/core/endpoint.py @@ -205,11 +205,13 @@ class Endpoint: def send_event(self, signal: dict[str, Any]) -> None: """Broadcast an event from this endpoint.""" - signal["endpoint"] = { - "id": self.id, - "unique_id": self.unique_id, - } - self.device.zha_send_event(signal) + self.device.zha_send_event( + { + const.ATTR_UNIQUE_ID: self.unique_id, + const.ATTR_ENDPOINT_ID: self.id, + **signal, + } + ) def claim_cluster_handlers(self, cluster_handlers: list[ClusterHandler]) -> None: """Claim cluster handlers.""" diff --git a/tests/components/zha/test_device_trigger.py b/tests/components/zha/test_device_trigger.py index 8c789c0fc598..29920eab8369 100644 --- a/tests/components/zha/test_device_trigger.py +++ b/tests/components/zha/test_device_trigger.py @@ -9,6 +9,7 @@ import zigpy.zcl.clusters.general as general import homeassistant.components.automation as automation from homeassistant.components.device_automation import DeviceAutomationType +from homeassistant.components.zha.core.const import ATTR_ENDPOINT_ID from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -190,7 +191,7 @@ async def test_if_fires_on_event(hass: HomeAssistant, mock_devices, calls) -> No zigpy_device.device_automation_triggers = { (SHAKEN, SHAKEN): {COMMAND: COMMAND_SHAKE}, (DOUBLE_PRESS, DOUBLE_PRESS): {COMMAND: COMMAND_DOUBLE}, - (SHORT_PRESS, SHORT_PRESS): {COMMAND: COMMAND_SINGLE}, + (SHORT_PRESS, SHORT_PRESS): {COMMAND: COMMAND_SINGLE, ATTR_ENDPOINT_ID: 1}, (LONG_PRESS, LONG_PRESS): {COMMAND: COMMAND_HOLD}, (LONG_RELEASE, LONG_RELEASE): {COMMAND: COMMAND_HOLD}, } From f8f7de5d5a5c48f98c257cd5167a9ef5a4d7fbbc Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 28 Apr 2023 18:17:48 +0200 Subject: [PATCH 045/822] Remove homeassistant from conversation dependencies (#92170) * Remove homeassistant from conversation dependencies * Fix tests --- homeassistant/components/conversation/manifest.json | 2 +- tests/components/assist_pipeline/conftest.py | 1 + tests/components/assist_pipeline/test_pipeline.py | 6 ++++++ tests/components/conversation/test_init.py | 5 +++++ tests/components/esphome/conftest.py | 7 +++++++ tests/components/voip/conftest.py | 6 ++++++ 6 files changed, 26 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 0221d80002c1..209f239df00b 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -2,7 +2,7 @@ "domain": "conversation", "name": "Conversation", "codeowners": ["@home-assistant/core", "@synesthesiam"], - "dependencies": ["homeassistant", "http"], + "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/conversation", "integration_type": "system", "iot_class": "local_push", diff --git a/tests/components/assist_pipeline/conftest.py b/tests/components/assist_pipeline/conftest.py index 1df52859ed96..54dc3705d2af 100644 --- a/tests/components/assist_pipeline/conftest.py +++ b/tests/components/assist_pipeline/conftest.py @@ -243,6 +243,7 @@ async def init_supporting_components( ) mock_platform(hass, "test.config_flow") + assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component(hass, tts.DOMAIN, {"tts": {"platform": "test"}}) assert await async_setup_component(hass, stt.DOMAIN, {"stt": {"platform": "test"}}) assert await async_setup_component(hass, "media_source", {}) diff --git a/tests/components/assist_pipeline/test_pipeline.py b/tests/components/assist_pipeline/test_pipeline.py index 4c71b4aedbda..f6a62a630d2b 100644 --- a/tests/components/assist_pipeline/test_pipeline.py +++ b/tests/components/assist_pipeline/test_pipeline.py @@ -25,6 +25,12 @@ from .conftest import MockSttPlatform, MockSttProvider, MockTTSPlatform, MockTTS from tests.common import MockModule, flush_store, mock_integration, mock_platform +@pytest.fixture(autouse=True) +async def load_homeassistant(hass) -> None: + """Load the homeassistant integration.""" + assert await async_setup_component(hass, "homeassistant", {}) + + async def test_load_datasets(hass: HomeAssistant, init_components) -> None: """Make sure that we can load/save data correctly.""" diff --git a/tests/components/conversation/test_init.py b/tests/components/conversation/test_init.py index 4d49b2d21ea9..6b981cbbdfa8 100644 --- a/tests/components/conversation/test_init.py +++ b/tests/components/conversation/test_init.py @@ -1162,6 +1162,7 @@ async def test_ws_api( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, payload ) -> None: """Test the Websocket conversation API.""" + assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component(hass, "conversation", {}) client = await hass_ws_client(hass) @@ -1192,6 +1193,7 @@ async def test_ws_prepare( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, agent_id ) -> None: """Test the Websocket prepare conversation API.""" + assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component(hass, "conversation", {}) agent = await conversation._get_agent_manager(hass).async_get_agent() assert isinstance(agent, conversation.DefaultAgent) @@ -1320,6 +1322,7 @@ async def test_custom_sentences_config( async def test_prepare_reload(hass: HomeAssistant) -> None: """Test calling the reload service.""" language = hass.config.language + assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component(hass, "conversation", {}) # Load intents @@ -1340,6 +1343,7 @@ async def test_prepare_reload(hass: HomeAssistant) -> None: async def test_prepare_fail(hass: HomeAssistant) -> None: """Test calling prepare with a non-existent language.""" + assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component(hass, "conversation", {}) # Load intents @@ -1387,6 +1391,7 @@ async def test_language_region( async def test_reload_on_new_component(hass: HomeAssistant) -> None: """Test intents being reloaded when a new component is loaded.""" language = hass.config.language + assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component(hass, "conversation", {}) # Load intents diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index a70686acbf6e..4c01c2f7c5a2 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -15,6 +15,7 @@ from homeassistant.components.esphome import ( ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component from . import DASHBOARD_HOST, DASHBOARD_PORT, DASHBOARD_SLUG @@ -31,6 +32,12 @@ def esphome_mock_async_zeroconf(mock_async_zeroconf): """Auto mock zeroconf.""" +@pytest.fixture(autouse=True) +async def load_homeassistant(hass) -> None: + """Load the homeassistant integration.""" + assert await async_setup_component(hass, "homeassistant", {}) + + @pytest.fixture def mock_config_entry(hass) -> MockConfigEntry: """Return the default mocked config entry.""" diff --git a/tests/components/voip/conftest.py b/tests/components/voip/conftest.py index 80d8eaa11c31..499d454cf4da 100644 --- a/tests/components/voip/conftest.py +++ b/tests/components/voip/conftest.py @@ -16,6 +16,12 @@ from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry +@pytest.fixture(autouse=True) +async def load_homeassistant(hass) -> None: + """Load the homeassistant integration.""" + assert await async_setup_component(hass, "homeassistant", {}) + + @pytest.fixture def config_entry(hass: HomeAssistant) -> MockConfigEntry: """Create a config entry.""" From cff7829a8d72034a8f1a069ec0ca17e86bea068f Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 28 Apr 2023 18:56:22 +0200 Subject: [PATCH 046/822] Fix mqtt subscribe debouncer initial delay too long when birth message is disabled (#92188) Fix mqtt subscribe deboucer initial delay --- homeassistant/components/mqtt/client.py | 3 +++ tests/components/mqtt/test_init.py | 13 +++++++++++++ 2 files changed, 16 insertions(+) diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index 55db1da6ff79..cd73ee8efb65 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -740,6 +740,9 @@ class MQTT: asyncio.run_coroutine_threadsafe( publish_birth_message(birth_message), self.hass.loop ) + else: + # Update subscribe cooldown period to a shorter time + self._subscribe_debouncer.set_timeout(SUBSCRIBE_COOLDOWN) async def _async_resubscribe(self) -> None: """Resubscribe on reconnect.""" diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 62ad74b6a094..498365de4a38 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -2062,6 +2062,19 @@ async def test_no_birth_message( await asyncio.sleep(0.2) mqtt_client_mock.publish.assert_not_called() + async def callback(msg: ReceiveMessage) -> None: + """Handle birth message.""" + + # Assert the subscribe debouncer subscribes after + # about SUBSCRIBE_COOLDOWN (0.1) sec + # but sooner than INITIAL_SUBSCRIBE_COOLDOWN (1.0) + + mqtt_client_mock.reset_mock() + await mqtt.async_subscribe(hass, "homeassistant/some-topic", callback) + await hass.async_block_till_done() + await asyncio.sleep(0.2) + mqtt_client_mock.subscribe.assert_called() + @pytest.mark.parametrize( "mqtt_config_entry_data", From 57f41958e6e0f191d6e0c34d9e99bd768b12a638 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Fran=C3=A7ois=20Roy?= Date: Fri, 28 Apr 2023 12:00:54 -0700 Subject: [PATCH 047/822] Add missing PRESET_MODE feature to BAF fans (#92200) --- homeassistant/components/baf/fan.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/baf/fan.py b/homeassistant/components/baf/fan.py index 360926363a57..a166c346f12f 100644 --- a/homeassistant/components/baf/fan.py +++ b/homeassistant/components/baf/fan.py @@ -39,7 +39,11 @@ async def async_setup_entry( class BAFFan(BAFEntity, FanEntity): """BAF ceiling fan component.""" - _attr_supported_features = FanEntityFeature.SET_SPEED | FanEntityFeature.DIRECTION + _attr_supported_features = ( + FanEntityFeature.SET_SPEED + | FanEntityFeature.DIRECTION + | FanEntityFeature.PRESET_MODE + ) _attr_preset_modes = [PRESET_MODE_AUTO] _attr_speed_count = SPEED_COUNT From 75560aab1b60db164b7110072d3bc00c67b4a878 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Fri, 28 Apr 2023 21:01:28 +0200 Subject: [PATCH 048/822] Update frontend to 20230428.0 (#92190) --- 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 409f70f554a6..e925e68b5731 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==20230427.0"] + "requirements": ["home-assistant-frontend==20230428.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 6f5adea209c1..dcc98f78a773 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -25,7 +25,7 @@ ha-av==10.0.0 hass-nabucasa==0.66.2 hassil==1.0.6 home-assistant-bluetooth==1.10.0 -home-assistant-frontend==20230427.0 +home-assistant-frontend==20230428.0 home-assistant-intents==2023.4.26 httpx==0.24.0 ifaddr==0.1.7 diff --git a/requirements_all.txt b/requirements_all.txt index ec03f6db4fe4..3c9a9380da18 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -911,7 +911,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230427.0 +home-assistant-frontend==20230428.0 # homeassistant.components.conversation home-assistant-intents==2023.4.26 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4ca7e9e64faa..4b2deadcef18 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -700,7 +700,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230427.0 +home-assistant-frontend==20230428.0 # homeassistant.components.conversation home-assistant-intents==2023.4.26 From 293fb374eda363e9d611f31860eebf7db3ffc5c2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 28 Apr 2023 21:03:16 +0200 Subject: [PATCH 049/822] Fix missing preset_mode feature in bond fans (#92202) --- homeassistant/components/bond/fan.py | 3 ++- tests/components/bond/test_fan.py | 14 ++++++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/bond/fan.py b/homeassistant/components/bond/fan.py index a856af83bb86..1512cf7b2b49 100644 --- a/homeassistant/components/bond/fan.py +++ b/homeassistant/components/bond/fan.py @@ -89,7 +89,8 @@ class BondFan(BondEntity, FanEntity): features |= FanEntityFeature.SET_SPEED if self._device.supports_direction(): features |= FanEntityFeature.DIRECTION - + if self._device.has_action(Action.BREEZE_ON): + features |= FanEntityFeature.PRESET_MODE return features @property diff --git a/tests/components/bond/test_fan.py b/tests/components/bond/test_fan.py index d3c03e0d8055..f2fa109af225 100644 --- a/tests/components/bond/test_fan.py +++ b/tests/components/bond/test_fan.py @@ -25,8 +25,14 @@ from homeassistant.components.fan import ( SERVICE_SET_DIRECTION, SERVICE_SET_PERCENTAGE, SERVICE_SET_PRESET_MODE, + FanEntityFeature, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, ) -from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -211,9 +217,9 @@ async def test_turn_on_fan_preset_mode(hass: HomeAssistant) -> None: bond_device_id="test-device-id", props={"max_speed": 6}, ) - assert hass.states.get("fan.name_1").attributes[ATTR_PRESET_MODES] == [ - PRESET_MODE_BREEZE - ] + state = hass.states.get("fan.name_1") + assert state.attributes[ATTR_PRESET_MODES] == [PRESET_MODE_BREEZE] + assert state.attributes[ATTR_SUPPORTED_FEATURES] & FanEntityFeature.PRESET_MODE with patch_bond_action() as mock_set_preset_mode, patch_bond_device_state(): await turn_fan_on(hass, "fan.name_1", preset_mode=PRESET_MODE_BREEZE) From e156d3132eede14d97d06e83b900110fea2d78e8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 28 Apr 2023 21:03:59 +0200 Subject: [PATCH 050/822] Ensure purge can cleanup old format detached states in the database (#92145) --- homeassistant/components/recorder/purge.py | 61 +++++++- homeassistant/components/recorder/queries.py | 16 ++ .../recorder/test_purge_v32_schema.py | 148 +++++++++++++++++- 3 files changed, 217 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/recorder/purge.py b/homeassistant/components/recorder/purge.py index 662be41b1c8e..95013de125dc 100644 --- a/homeassistant/components/recorder/purge.py +++ b/homeassistant/components/recorder/purge.py @@ -34,6 +34,7 @@ from .queries import ( find_event_types_to_purge, find_events_to_purge, find_latest_statistics_runs_run_id, + find_legacy_detached_states_and_attributes_to_purge, find_legacy_event_state_and_attributes_and_data_ids_to_purge, find_legacy_row, find_short_term_statistics_to_purge, @@ -146,7 +147,28 @@ def _purge_legacy_format( _purge_unused_attributes_ids(instance, session, attributes_ids) _purge_event_ids(session, event_ids) _purge_unused_data_ids(instance, session, data_ids) - return bool(event_ids or state_ids or attributes_ids or data_ids) + + # The database may still have some rows that have an event_id but are not + # linked to any event. These rows are not linked to any event because the + # event was deleted. We need to purge these rows as well or we will never + # switch to the new format which will prevent us from purging any events + # that happened after the detached states. + ( + detached_state_ids, + detached_attributes_ids, + ) = _select_legacy_detached_state_and_attributes_and_data_ids_to_purge( + session, purge_before + ) + _purge_state_ids(instance, session, detached_state_ids) + _purge_unused_attributes_ids(instance, session, detached_attributes_ids) + return bool( + event_ids + or state_ids + or attributes_ids + or data_ids + or detached_state_ids + or detached_attributes_ids + ) def _purge_states_and_attributes_ids( @@ -412,6 +434,31 @@ def _select_short_term_statistics_to_purge( return [statistic.id for statistic in statistics] +def _select_legacy_detached_state_and_attributes_and_data_ids_to_purge( + session: Session, purge_before: datetime +) -> tuple[set[int], set[int]]: + """Return a list of state, and attribute ids to purge. + + We do not link these anymore since state_change events + do not exist in the events table anymore, however we + still need to be able to purge them. + """ + states = session.execute( + find_legacy_detached_states_and_attributes_to_purge( + dt_util.utc_to_timestamp(purge_before) + ) + ).all() + _LOGGER.debug("Selected %s state ids to remove", len(states)) + state_ids = set() + attributes_ids = set() + for state in states: + if state_id := state.state_id: + state_ids.add(state_id) + if attributes_id := state.attributes_id: + attributes_ids.add(attributes_id) + return state_ids, attributes_ids + + def _select_legacy_event_state_and_attributes_and_data_ids_to_purge( session: Session, purge_before: datetime ) -> tuple[set[int], set[int], set[int], set[int]]: @@ -433,12 +480,12 @@ def _select_legacy_event_state_and_attributes_and_data_ids_to_purge( data_ids = set() for event in events: event_ids.add(event.event_id) - if event.state_id: - state_ids.add(event.state_id) - if event.attributes_id: - attributes_ids.add(event.attributes_id) - if event.data_id: - data_ids.add(event.data_id) + if state_id := event.state_id: + state_ids.add(state_id) + if attributes_id := event.attributes_id: + attributes_ids.add(attributes_id) + if data_id := event.data_id: + data_ids.add(data_id) return event_ids, state_ids, attributes_ids, data_ids diff --git a/homeassistant/components/recorder/queries.py b/homeassistant/components/recorder/queries.py index f8a1b769d87b..49f66fdcd682 100644 --- a/homeassistant/components/recorder/queries.py +++ b/homeassistant/components/recorder/queries.py @@ -678,6 +678,22 @@ def find_legacy_event_state_and_attributes_and_data_ids_to_purge( ) +def find_legacy_detached_states_and_attributes_to_purge( + purge_before: float, +) -> StatementLambdaElement: + """Find states rows with event_id set but not linked event_id in Events.""" + return lambda_stmt( + lambda: select(States.state_id, States.attributes_id) + .outerjoin(Events, States.event_id == Events.event_id) + .filter(States.event_id.isnot(None)) + .filter( + (States.last_updated_ts < purge_before) | States.last_updated_ts.is_(None) + ) + .filter(Events.event_id.is_(None)) + .limit(SQLITE_MAX_BIND_VARS) + ) + + def find_legacy_row() -> StatementLambdaElement: """Check if there are still states in the table with an event_id.""" # https://github.com/sqlalchemy/sqlalchemy/issues/9189 diff --git a/tests/components/recorder/test_purge_v32_schema.py b/tests/components/recorder/test_purge_v32_schema.py index 433ff01eb91f..613c17b3d39a 100644 --- a/tests/components/recorder/test_purge_v32_schema.py +++ b/tests/components/recorder/test_purge_v32_schema.py @@ -8,6 +8,7 @@ from unittest.mock import MagicMock, patch from freezegun import freeze_time import pytest +from sqlalchemy import text, update from sqlalchemy.exc import DatabaseError, OperationalError from sqlalchemy.orm.session import Session @@ -1000,7 +1001,7 @@ async def test_purge_many_old_events( async def test_purge_can_mix_legacy_and_new_format( async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant ) -> None: - """Test purging with legacy a new events.""" + """Test purging with legacy and new events.""" instance = await async_setup_recorder_instance(hass) await _async_attach_db_engine(hass) @@ -1018,6 +1019,7 @@ async def test_purge_can_mix_legacy_and_new_format( utcnow = dt_util.utcnow() eleven_days_ago = utcnow - timedelta(days=11) + with session_scope(hass=hass) as session: broken_state_no_time = States( event_id=None, @@ -1104,6 +1106,150 @@ async def test_purge_can_mix_legacy_and_new_format( assert states_without_event_id.count() == 1 +async def test_purge_can_mix_legacy_and_new_format_with_detached_state( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + recorder_db_url: str, +) -> None: + """Test purging with legacy and new events with a detached state.""" + if recorder_db_url.startswith(("mysql://", "postgresql://")): + return pytest.skip("This tests disables foreign key checks on SQLite") + + instance = await async_setup_recorder_instance(hass) + await _async_attach_db_engine(hass) + + await async_wait_recording_done(hass) + # New databases are no longer created with the legacy events index + assert instance.use_legacy_events_index is False + + def _recreate_legacy_events_index(): + """Recreate the legacy events index since its no longer created on new instances.""" + migration._create_index(instance.get_session, "states", "ix_states_event_id") + instance.use_legacy_events_index = True + + await instance.async_add_executor_job(_recreate_legacy_events_index) + assert instance.use_legacy_events_index is True + + with session_scope(hass=hass) as session: + session.execute(text("PRAGMA foreign_keys = OFF")) + + utcnow = dt_util.utcnow() + eleven_days_ago = utcnow - timedelta(days=11) + + with session_scope(hass=hass) as session: + broken_state_no_time = States( + event_id=None, + entity_id="orphened.state", + last_updated_ts=None, + last_changed_ts=None, + ) + session.add(broken_state_no_time) + detached_state_deleted_event_id = States( + event_id=99999999999, + entity_id="event.deleted", + last_updated_ts=1, + last_changed_ts=None, + ) + session.add(detached_state_deleted_event_id) + detached_state_deleted_event_id.last_changed = None + detached_state_deleted_event_id.last_changed_ts = None + detached_state_deleted_event_id.last_updated = None + detached_state_deleted_event_id = States( + event_id=99999999999, + entity_id="event.deleted.no_time", + last_updated_ts=None, + last_changed_ts=None, + ) + detached_state_deleted_event_id.last_changed = None + detached_state_deleted_event_id.last_changed_ts = None + detached_state_deleted_event_id.last_updated = None + detached_state_deleted_event_id.last_updated_ts = None + session.add(detached_state_deleted_event_id) + start_id = 50000 + for event_id in range(start_id, start_id + 50): + _add_state_and_state_changed_event( + session, + "sensor.excluded", + "purgeme", + eleven_days_ago, + event_id, + ) + with session_scope(hass=hass) as session: + session.execute( + update(States) + .where(States.entity_id == "event.deleted.no_time") + .values(last_updated_ts=None) + ) + + await _add_test_events(hass, 50) + await _add_events_with_event_data(hass, 50) + with session_scope(hass=hass) as session: + for _ in range(50): + _add_state_without_event_linkage( + session, "switch.random", "on", eleven_days_ago + ) + states_with_event_id = session.query(States).filter( + States.event_id.is_not(None) + ) + states_without_event_id = session.query(States).filter( + States.event_id.is_(None) + ) + + assert states_with_event_id.count() == 52 + assert states_without_event_id.count() == 51 + + purge_before = dt_util.utcnow() - timedelta(days=4) + finished = purge_old_data( + instance, + purge_before, + repack=False, + ) + assert not finished + assert states_with_event_id.count() == 0 + assert states_without_event_id.count() == 51 + # At this point all the legacy states are gone + # and we switch methods + purge_before = dt_util.utcnow() - timedelta(days=4) + finished = purge_old_data( + instance, + purge_before, + repack=False, + events_batch_size=1, + states_batch_size=1, + ) + # Since we only allow one iteration, we won't + # check if we are finished this loop similar + # to the legacy method + assert not finished + assert states_with_event_id.count() == 0 + assert states_without_event_id.count() == 1 + finished = purge_old_data( + instance, + purge_before, + repack=False, + events_batch_size=100, + states_batch_size=100, + ) + assert finished + assert states_with_event_id.count() == 0 + assert states_without_event_id.count() == 1 + _add_state_without_event_linkage( + session, "switch.random", "on", eleven_days_ago + ) + assert states_with_event_id.count() == 0 + assert states_without_event_id.count() == 2 + finished = purge_old_data( + instance, + purge_before, + repack=False, + ) + assert finished + # The broken state without a timestamp + # does not prevent future purges. Its ignored. + assert states_with_event_id.count() == 0 + assert states_without_event_id.count() == 1 + + async def test_purge_entities_keep_days( async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant, From d366b438667dd305095874fe635ee6354348a27f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 28 Apr 2023 21:06:32 +0200 Subject: [PATCH 051/822] Fix lingering timer in lifx discovery (#92185) --- homeassistant/components/lifx/__init__.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lifx/__init__.py b/homeassistant/components/lifx/__init__.py index f0c38cdfb111..41aa58fb9626 100644 --- a/homeassistant/components/lifx/__init__.py +++ b/homeassistant/components/lifx/__init__.py @@ -19,7 +19,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STARTED, Platform, ) -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_call_later, async_track_time_interval @@ -125,7 +125,7 @@ class LIFXDiscoveryManager: self.migrating, ) self._cancel_discovery = async_track_time_interval( - self.hass, self.async_discovery, discovery_interval + self.hass, self.async_discovery, discovery_interval, cancel_on_shutdown=True ) async def async_discovery(self, *_: Any) -> None: @@ -174,7 +174,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # to reduce the risk we miss devices because the event # loop is blocked at startup. discovery_manager.async_setup_discovery_interval() - async_call_later(hass, DISCOVERY_COOLDOWN, _async_delayed_discovery) + async_call_later( + hass, + DISCOVERY_COOLDOWN, + HassJob(_async_delayed_discovery, cancel_on_shutdown=True), + ) hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STARTED, discovery_manager.async_discovery ) From a0cd0b3c6cdc926138485bd446b70e89d45af7a8 Mon Sep 17 00:00:00 2001 From: Sven Serlier <85389871+wrt54g@users.noreply.github.com> Date: Fri, 28 Apr 2023 21:07:17 +0200 Subject: [PATCH 052/822] Fix typo in anova integration (#92191) --- homeassistant/components/anova/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/anova/strings.json b/homeassistant/components/anova/strings.json index 19d0e52b7d25..b14246a392d7 100644 --- a/homeassistant/components/anova/strings.json +++ b/homeassistant/components/anova/strings.json @@ -17,7 +17,7 @@ "error": { "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]", - "no_devices_found": "No devices were found. Make sure you have at least one Anova device online" + "no_devices_found": "No devices were found. Make sure you have at least one Anova device online." } }, "entity": { From 397a5ff992983ecb24c8a9e0c139e7c869f57c5f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 28 Apr 2023 21:08:08 +0200 Subject: [PATCH 053/822] Fix lingering timer in broadlink (#92179) --- homeassistant/components/broadlink/__init__.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/broadlink/__init__.py b/homeassistant/components/broadlink/__init__.py index 559aae25abff..3b1312a64c5c 100644 --- a/homeassistant/components/broadlink/__init__.py +++ b/homeassistant/components/broadlink/__init__.py @@ -31,12 +31,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a Broadlink device from a config entry.""" data: BroadlinkData = hass.data[DOMAIN] + device = BroadlinkDevice(hass, entry) + if not await device.async_setup(): + return False if data.heartbeat is None: data.heartbeat = BroadlinkHeartbeat(hass) hass.async_create_task(data.heartbeat.async_setup()) - - device = BroadlinkDevice(hass, entry) - return await device.async_setup() + return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: From 8c64eda58f8f8f2ce65d87beba8ad16db0aa011b Mon Sep 17 00:00:00 2001 From: Sven Serlier <85389871+wrt54g@users.noreply.github.com> Date: Fri, 28 Apr 2023 21:12:26 +0200 Subject: [PATCH 054/822] Update URLs in PR template (#92194) --- .github/PULL_REQUEST_TEMPLATE.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index c64efda390e7..4bc1442d9e9b 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -104,8 +104,8 @@ To help with the load of incoming pull requests: Below, some useful links you could explore: --> -[dev-checklist]: https://developers.home-assistant.io/docs/en/development_checklist.html -[manifest-docs]: https://developers.home-assistant.io/docs/en/creating_integration_manifest.html -[quality-scale]: https://developers.home-assistant.io/docs/en/next/integration_quality_scale_index.html +[dev-checklist]: https://developers.home-assistant.io/docs/development_checklist/ +[manifest-docs]: https://developers.home-assistant.io/docs/creating_integration_manifest/ +[quality-scale]: https://developers.home-assistant.io/docs/integration_quality_scale_index/ [docs-repository]: https://github.com/home-assistant/home-assistant.io [perfect-pr]: https://developers.home-assistant.io/docs/review-process/#creating-the-perfect-pr From 67a7de18698d893853573cc691baaecdd8939afa Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 28 Apr 2023 21:14:37 +0200 Subject: [PATCH 055/822] Improve init type hints in enocean (#92176) --- .../components/enocean/binary_sensor.py | 14 ++++++--- homeassistant/components/enocean/device.py | 2 +- homeassistant/components/enocean/light.py | 8 ++--- homeassistant/components/enocean/sensor.py | 31 ++++++++++--------- homeassistant/components/enocean/switch.py | 8 ++--- 5 files changed, 36 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/enocean/binary_sensor.py b/homeassistant/components/enocean/binary_sensor.py index e18542241da9..e7f94647941a 100644 --- a/homeassistant/components/enocean/binary_sensor.py +++ b/homeassistant/components/enocean/binary_sensor.py @@ -7,6 +7,7 @@ import voluptuous as vol from homeassistant.components.binary_sensor import ( DEVICE_CLASSES_SCHEMA, PLATFORM_SCHEMA, + BinarySensorDeviceClass, BinarySensorEntity, ) from homeassistant.const import CONF_DEVICE_CLASS, CONF_ID, CONF_NAME @@ -37,9 +38,9 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Binary Sensor platform for EnOcean.""" - dev_id = config.get(CONF_ID) - dev_name = config.get(CONF_NAME) - device_class = config.get(CONF_DEVICE_CLASS) + dev_id: list[int] = config[CONF_ID] + dev_name: str = config[CONF_NAME] + device_class: BinarySensorDeviceClass | None = config.get(CONF_DEVICE_CLASS) add_entities([EnOceanBinarySensor(dev_id, dev_name, device_class)]) @@ -52,7 +53,12 @@ class EnOceanBinarySensor(EnOceanEntity, BinarySensorEntity): - F6-02-02 (Light and Blind Control - Application Style 1) """ - def __init__(self, dev_id, dev_name, device_class): + def __init__( + self, + dev_id: list[int], + dev_name: str, + device_class: BinarySensorDeviceClass | None, + ) -> None: """Initialize the EnOcean binary sensor.""" super().__init__(dev_id, dev_name) self._device_class = device_class diff --git a/homeassistant/components/enocean/device.py b/homeassistant/components/enocean/device.py index 0bd084742b54..1c98b4dd2341 100644 --- a/homeassistant/components/enocean/device.py +++ b/homeassistant/components/enocean/device.py @@ -11,7 +11,7 @@ from .const import SIGNAL_RECEIVE_MESSAGE, SIGNAL_SEND_MESSAGE class EnOceanEntity(Entity): """Parent class for all entities associated with the EnOcean component.""" - def __init__(self, dev_id, dev_name="EnOcean device"): + def __init__(self, dev_id: list[int], dev_name: str) -> None: """Initialize the device.""" self.dev_id = dev_id self.dev_name = dev_name diff --git a/homeassistant/components/enocean/light.py b/homeassistant/components/enocean/light.py index 479723bd0516..e2a194af8ba0 100644 --- a/homeassistant/components/enocean/light.py +++ b/homeassistant/components/enocean/light.py @@ -41,9 +41,9 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the EnOcean light platform.""" - sender_id = config.get(CONF_SENDER_ID) - dev_name = config.get(CONF_NAME) - dev_id = config.get(CONF_ID) + sender_id: list[int] = config[CONF_SENDER_ID] + dev_name: str = config[CONF_NAME] + dev_id: list[int] = config[CONF_ID] add_entities([EnOceanLight(sender_id, dev_id, dev_name)]) @@ -54,7 +54,7 @@ class EnOceanLight(EnOceanEntity, LightEntity): _attr_color_mode = ColorMode.BRIGHTNESS _attr_supported_color_modes = {ColorMode.BRIGHTNESS} - def __init__(self, sender_id, dev_id, dev_name): + def __init__(self, sender_id: list[int], dev_id: list[int], dev_name: str) -> None: """Initialize the EnOcean light source.""" super().__init__(dev_id, dev_name) self._on_state = False diff --git a/homeassistant/components/enocean/sensor.py b/homeassistant/components/enocean/sensor.py index ae2110b31740..4fa3b25ed06a 100644 --- a/homeassistant/components/enocean/sensor.py +++ b/homeassistant/components/enocean/sensor.py @@ -117,16 +117,16 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up an EnOcean sensor device.""" - dev_id = config[CONF_ID] - dev_name = config[CONF_NAME] - sensor_type = config[CONF_DEVICE_CLASS] + dev_id: list[int] = config[CONF_ID] + dev_name: str = config[CONF_NAME] + sensor_type: str = config[CONF_DEVICE_CLASS] entities: list[EnOceanSensor] = [] if sensor_type == SENSOR_TYPE_TEMPERATURE: - temp_min = config[CONF_MIN_TEMP] - temp_max = config[CONF_MAX_TEMP] - range_from = config[CONF_RANGE_FROM] - range_to = config[CONF_RANGE_TO] + temp_min: int = config[CONF_MIN_TEMP] + temp_max: int = config[CONF_MAX_TEMP] + range_from: int = config[CONF_RANGE_FROM] + range_to: int = config[CONF_RANGE_TO] entities = [ EnOceanTemperatureSensor( dev_id, @@ -155,7 +155,10 @@ class EnOceanSensor(EnOceanEntity, RestoreEntity, SensorEntity): """Representation of an EnOcean sensor device such as a power meter.""" def __init__( - self, dev_id, dev_name, description: EnOceanSensorEntityDescription + self, + dev_id: list[int], + dev_name: str, + description: EnOceanSensorEntityDescription, ) -> None: """Initialize the EnOcean sensor device.""" super().__init__(dev_id, dev_name) @@ -217,14 +220,14 @@ class EnOceanTemperatureSensor(EnOceanSensor): def __init__( self, - dev_id, - dev_name, + dev_id: list[int], + dev_name: str, description: EnOceanSensorEntityDescription, *, - scale_min, - scale_max, - range_from, - range_to, + scale_min: int, + scale_max: int, + range_from: int, + range_to: int, ) -> None: """Initialize the EnOcean temperature sensor device.""" super().__init__(dev_id, dev_name, description) diff --git a/homeassistant/components/enocean/switch.py b/homeassistant/components/enocean/switch.py index 11ca8a2a625e..c69821c83727 100644 --- a/homeassistant/components/enocean/switch.py +++ b/homeassistant/components/enocean/switch.py @@ -65,9 +65,9 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the EnOcean switch platform.""" - channel = config.get(CONF_CHANNEL) - dev_id = config.get(CONF_ID) - dev_name = config.get(CONF_NAME) + channel: int = config[CONF_CHANNEL] + dev_id: list[int] = config[CONF_ID] + dev_name: str = config[CONF_NAME] _migrate_to_new_unique_id(hass, dev_id, channel) async_add_entities([EnOceanSwitch(dev_id, dev_name, channel)]) @@ -76,7 +76,7 @@ async def async_setup_platform( class EnOceanSwitch(EnOceanEntity, SwitchEntity): """Representation of an EnOcean switch device.""" - def __init__(self, dev_id, dev_name, channel): + def __init__(self, dev_id: list[int], dev_name: str, channel: int) -> None: """Initialize the EnOcean switch device.""" super().__init__(dev_id, dev_name) self._light = None From 07d1a16efd4930c23f565ff0b714d38ed9e36bd7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 28 Apr 2023 21:15:39 +0200 Subject: [PATCH 056/822] Add as_dict cache to Context and Event (#92162) --- .../homeassistant/triggers/event.py | 2 +- homeassistant/core.py | 44 ++++++++++++------- tests/components/recorder/test_models.py | 4 +- 3 files changed, 33 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/homeassistant/triggers/event.py b/homeassistant/components/homeassistant/triggers/event.py index d84b04c36527..d0e74d5b04e3 100644 --- a/homeassistant/components/homeassistant/triggers/event.py +++ b/homeassistant/components/homeassistant/triggers/event.py @@ -91,7 +91,7 @@ async def async_attach_trigger( if event_data_schema: event_data_schema(event.data) if event_context_schema: - event_context_schema(event.context.as_dict()) + event_context_schema(dict(event.context.as_dict())) except vol.Invalid: # If event doesn't match, skip event return False diff --git a/homeassistant/core.py b/homeassistant/core.py index f7cfcf442051..48c69f35956b 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -850,7 +850,7 @@ class HomeAssistant: class Context: """The context that triggered something.""" - __slots__ = ("user_id", "parent_id", "id", "origin_event") + __slots__ = ("user_id", "parent_id", "id", "origin_event", "_as_dict") def __init__( self, @@ -863,14 +863,23 @@ class Context: self.user_id = user_id self.parent_id = parent_id self.origin_event: Event | None = None + self._as_dict: ReadOnlyDict[str, str | None] | None = None def __eq__(self, other: Any) -> bool: """Compare contexts.""" return bool(self.__class__ == other.__class__ and self.id == other.id) - def as_dict(self) -> dict[str, str | None]: + def as_dict(self) -> ReadOnlyDict[str, str | None]: """Return a dictionary representation of the context.""" - return {"id": self.id, "parent_id": self.parent_id, "user_id": self.user_id} + if not self._as_dict: + self._as_dict = ReadOnlyDict( + { + "id": self.id, + "parent_id": self.parent_id, + "user_id": self.user_id, + } + ) + return self._as_dict class EventOrigin(enum.Enum): @@ -887,7 +896,7 @@ class EventOrigin(enum.Enum): class Event: """Representation of an event within the bus.""" - __slots__ = ["event_type", "data", "origin", "time_fired", "context"] + __slots__ = ("event_type", "data", "origin", "time_fired", "context", "_as_dict") def __init__( self, @@ -905,19 +914,24 @@ class Event: self.context: Context = context or Context( id=ulid_util.ulid_at_time(dt_util.utc_to_timestamp(self.time_fired)) ) + self._as_dict: ReadOnlyDict[str, Any] | None = None - def as_dict(self) -> dict[str, Any]: + def as_dict(self) -> ReadOnlyDict[str, Any]: """Create a dict representation of this Event. Async friendly. """ - return { - "event_type": self.event_type, - "data": dict(self.data), - "origin": str(self.origin.value), - "time_fired": self.time_fired.isoformat(), - "context": self.context.as_dict(), - } + if not self._as_dict: + self._as_dict = ReadOnlyDict( + { + "event_type": self.event_type, + "data": ReadOnlyDict(self.data), + "origin": str(self.origin.value), + "time_fired": self.time_fired.isoformat(), + "context": self.context.as_dict(), + } + ) + return self._as_dict def __repr__(self) -> str: """Return the representation.""" @@ -1189,7 +1203,7 @@ class State: object_id: Object id of this state. """ - __slots__ = [ + __slots__ = ( "entity_id", "state", "attributes", @@ -1200,7 +1214,7 @@ class State: "object_id", "_as_dict", "_as_compressed_state", - ] + ) def __init__( self, @@ -1265,7 +1279,7 @@ class State: "attributes": self.attributes, "last_changed": last_changed_isoformat, "last_updated": last_updated_isoformat, - "context": ReadOnlyDict(self.context.as_dict()), + "context": self.context.as_dict(), } ) return self._as_dict diff --git a/tests/components/recorder/test_models.py b/tests/components/recorder/test_models.py index f47f1d3e78b0..8797c153a7b5 100644 --- a/tests/components/recorder/test_models.py +++ b/tests/components/recorder/test_models.py @@ -256,7 +256,9 @@ async def test_event_to_db_model() -> None: assert native.as_dict() == event.as_dict() native = Events.from_event(event).to_native() - event.data = {} + native.data = ( + event.data + ) # data is not set by from_event as its in the event_data table native.event_type = event.event_type assert native.as_dict() == event.as_dict() From c303487c1bf234bad2901882476dceefec2243a9 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 28 Apr 2023 21:16:08 +0200 Subject: [PATCH 057/822] Fix lingering timer in feedreader (#92180) --- homeassistant/components/feedreader/__init__.py | 7 ++++++- tests/components/feedreader/test_init.py | 8 ++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/feedreader/__init__.py b/homeassistant/components/feedreader/__init__.py index 50404bb96dce..6be0e3c219f6 100644 --- a/homeassistant/components/feedreader/__init__.py +++ b/homeassistant/components/feedreader/__init__.py @@ -93,7 +93,12 @@ class FeedManager: def _init_regular_updates(self, hass: HomeAssistant) -> None: """Schedule regular updates at the top of the clock.""" - track_time_interval(hass, lambda now: self._update(), self._scan_interval) + track_time_interval( + hass, + lambda now: self._update(), + self._scan_interval, + cancel_on_shutdown=True, + ) @property def last_update_successful(self) -> bool: diff --git a/tests/components/feedreader/test_init.py b/tests/components/feedreader/test_init.py index 495a9f83641e..61851559969e 100644 --- a/tests/components/feedreader/test_init.py +++ b/tests/components/feedreader/test_init.py @@ -85,7 +85,9 @@ async def test_setup_one_feed(hass: HomeAssistant) -> None: assert await async_setup_component(hass, feedreader.DOMAIN, VALID_CONFIG_1) await hass.async_block_till_done() - track_method.assert_called_once_with(hass, mock.ANY, DEFAULT_SCAN_INTERVAL) + track_method.assert_called_once_with( + hass, mock.ANY, DEFAULT_SCAN_INTERVAL, cancel_on_shutdown=True + ) async def test_setup_scan_interval(hass: HomeAssistant) -> None: @@ -96,7 +98,9 @@ async def test_setup_scan_interval(hass: HomeAssistant) -> None: assert await async_setup_component(hass, feedreader.DOMAIN, VALID_CONFIG_2) await hass.async_block_till_done() - track_method.assert_called_once_with(hass, mock.ANY, timedelta(seconds=60)) + track_method.assert_called_once_with( + hass, mock.ANY, timedelta(seconds=60), cancel_on_shutdown=True + ) async def test_setup_max_entries(hass: HomeAssistant) -> None: From c483860560e1ced51701cb4ed89e00640ae0812c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 28 Apr 2023 21:16:26 +0200 Subject: [PATCH 058/822] Fix lingering timer in qld_bushfire (#92189) --- homeassistant/components/qld_bushfire/geo_location.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/qld_bushfire/geo_location.py b/homeassistant/components/qld_bushfire/geo_location.py index 1adddc485599..3606b664a3fd 100644 --- a/homeassistant/components/qld_bushfire/geo_location.py +++ b/homeassistant/components/qld_bushfire/geo_location.py @@ -126,7 +126,10 @@ class QldBushfireFeedEntityManager: def _init_regular_updates(self) -> None: """Schedule regular updates at the specified interval.""" track_time_interval( - self._hass, lambda now: self._feed_manager.update(), self._scan_interval + self._hass, + lambda now: self._feed_manager.update(), + self._scan_interval, + cancel_on_shutdown=True, ) def get_entry(self, external_id: str) -> QldBushfireAlertFeedEntry | None: From 65837c9075814709d40067aff64992d623b6f8f4 Mon Sep 17 00:00:00 2001 From: jjlawren Date: Fri, 28 Apr 2023 16:10:48 -0500 Subject: [PATCH 059/822] Bump `sonos-websocket` to 0.1.0 (#92209) Bump sonos-websocket to 0.1.0 --- homeassistant/components/sonos/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index 9c6f93fc2a46..4a05053940c3 100644 --- a/homeassistant/components/sonos/manifest.json +++ b/homeassistant/components/sonos/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/sonos", "iot_class": "local_push", "loggers": ["soco"], - "requirements": ["soco==0.29.1", "sonos-websocket==0.0.5"], + "requirements": ["soco==0.29.1", "sonos-websocket==0.1.0"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:ZonePlayer:1" diff --git a/requirements_all.txt b/requirements_all.txt index 3c9a9380da18..a1d87e139e91 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2381,7 +2381,7 @@ solax==0.3.0 somfy-mylink-synergy==1.0.6 # homeassistant.components.sonos -sonos-websocket==0.0.5 +sonos-websocket==0.1.0 # homeassistant.components.marytts speak2mary==1.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4b2deadcef18..8afff4ee9b4c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1714,7 +1714,7 @@ solax==0.3.0 somfy-mylink-synergy==1.0.6 # homeassistant.components.sonos -sonos-websocket==0.0.5 +sonos-websocket==0.1.0 # homeassistant.components.marytts speak2mary==1.4.0 From 96455c74f03206c02ba46d662e4c4e861dd8aefa Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Fri, 28 Apr 2023 23:24:35 -0400 Subject: [PATCH 060/822] Use enum sensor device class in Z-Wave (#92029) * Use enum sensor device class where appropriate * update docstring --- homeassistant/components/zwave_js/sensor.py | 61 +++++++++++++++------ 1 file changed, 44 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 7a0532f00eb4..f7fe5f52d791 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -491,7 +491,7 @@ class ZWaveMeterSensor(ZWaveNumericSensor): class ZWaveListSensor(ZwaveSensorBase): - """Representation of a Z-Wave Numeric sensor with multiple states.""" + """Representation of a Z-Wave List sensor with multiple states.""" def __init__( self, @@ -509,19 +509,31 @@ class ZWaveListSensor(ZwaveSensorBase): # Entity class attributes self._attr_name = self.generate_name(include_value_name=True) + @property + def device_class(self) -> SensorDeviceClass | None: + """Return sensor device class.""" + if super().device_class is not None: + return super().device_class + if self.info.primary_value.metadata.states: + return SensorDeviceClass.ENUM + return None + + @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 native_value(self) -> str | None: """Return state of the sensor.""" if self.info.primary_value.value is None: return None - if ( - str(self.info.primary_value.value) - not in self.info.primary_value.metadata.states - ): - return str(self.info.primary_value.value) - return str( - self.info.primary_value.metadata.states[str(self.info.primary_value.value)] - ) + key = str(self.info.primary_value.value) + if key not in self.info.primary_value.metadata.states: + return key + return str(self.info.primary_value.metadata.states[key]) @property def extra_state_attributes(self) -> dict[str, str] | None: @@ -557,22 +569,37 @@ class ZWaveConfigParameterSensor(ZwaveSensorBase): name_prefix="Config parameter", ) + @property + def device_class(self) -> SensorDeviceClass | None: + """Return sensor device class.""" + if super().device_class is not None: + return super().device_class + if ( + self._primary_value.configuration_value_type + == ConfigurationValueType.ENUMERATED + ): + return SensorDeviceClass.ENUM + return None + + @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 native_value(self) -> str | None: """Return state of the sensor.""" if self.info.primary_value.value is None: return None + key = str(self.info.primary_value.value) if ( self._primary_value.configuration_value_type == ConfigurationValueType.RANGE - or ( - str(self.info.primary_value.value) - not in self.info.primary_value.metadata.states - ) + or (key not in self.info.primary_value.metadata.states) ): - return str(self.info.primary_value.value) - return str( - self.info.primary_value.metadata.states[str(self.info.primary_value.value)] - ) + return key + return str(self.info.primary_value.metadata.states[key]) @property def extra_state_attributes(self) -> dict[str, str] | None: From 4511e91cfd58fd097e1680d67c48bf22c25a47ad Mon Sep 17 00:00:00 2001 From: Michael Davie Date: Sat, 29 Apr 2023 01:17:52 -0400 Subject: [PATCH 061/822] Bump env_canada to 0.5.34 (#92216) Bump env_canada to v.0.5.34 --- homeassistant/components/environment_canada/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/environment_canada/manifest.json b/homeassistant/components/environment_canada/manifest.json index 17e0ed6e2ac0..6262a28302fa 100644 --- a/homeassistant/components/environment_canada/manifest.json +++ b/homeassistant/components/environment_canada/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/environment_canada", "iot_class": "cloud_polling", "loggers": ["env_canada"], - "requirements": ["env_canada==0.5.33"] + "requirements": ["env_canada==0.5.34"] } diff --git a/requirements_all.txt b/requirements_all.txt index a1d87e139e91..c003817a34e0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -665,7 +665,7 @@ enocean==0.50 enturclient==0.2.4 # homeassistant.components.environment_canada -env_canada==0.5.33 +env_canada==0.5.34 # homeassistant.components.enphase_envoy envoy_reader==0.20.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8afff4ee9b4c..320acdbae03c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -524,7 +524,7 @@ energyzero==0.4.1 enocean==0.50 # homeassistant.components.environment_canada -env_canada==0.5.33 +env_canada==0.5.34 # homeassistant.components.enphase_envoy envoy_reader==0.20.1 From 282d5f4488504e2dc33c8a03befaaa5e1d51055a Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 29 Apr 2023 00:57:30 -0700 Subject: [PATCH 062/822] Add more detail to invalid rrule calendar error message (#92222) Co-authored-by: Martin Hjelmare --- homeassistant/components/calendar/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index aedfafbf3686..0f047bf37585 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -164,7 +164,7 @@ def _validate_rrule(value: Any) -> str: try: rrulestr(value) except ValueError as err: - raise vol.Invalid(f"Invalid rrule: {str(err)}") from err + raise vol.Invalid(f"Invalid rrule '{value}': {err}") from err # Example format: FREQ=DAILY;UNTIL=... rule_parts = dict(s.split("=", 1) for s in value.split(";")) From 58c5d9895880a1b8650a001d9a2974fae8820963 Mon Sep 17 00:00:00 2001 From: Tom Harris Date: Sat, 29 Apr 2023 05:16:04 -0400 Subject: [PATCH 063/822] Fix Insteon scenes with disabled entities (#92137) --- homeassistant/components/insteon/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/insteon/manifest.json b/homeassistant/components/insteon/manifest.json index 08adce918c1e..cc8495384b1b 100644 --- a/homeassistant/components/insteon/manifest.json +++ b/homeassistant/components/insteon/manifest.json @@ -18,7 +18,7 @@ "loggers": ["pyinsteon", "pypubsub"], "requirements": [ "pyinsteon==1.4.2", - "insteon-frontend-home-assistant==0.3.4" + "insteon-frontend-home-assistant==0.3.5" ], "usb": [ { diff --git a/requirements_all.txt b/requirements_all.txt index c003817a34e0..0945f790b2db 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -980,7 +980,7 @@ influxdb==5.3.1 inkbird-ble==0.5.6 # homeassistant.components.insteon -insteon-frontend-home-assistant==0.3.4 +insteon-frontend-home-assistant==0.3.5 # homeassistant.components.intellifire intellifire4py==2.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 320acdbae03c..6ecb2e3b8bc3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -748,7 +748,7 @@ influxdb==5.3.1 inkbird-ble==0.5.6 # homeassistant.components.insteon -insteon-frontend-home-assistant==0.3.4 +insteon-frontend-home-assistant==0.3.5 # homeassistant.components.intellifire intellifire4py==2.2.2 From 7dfbfd45aeaaf7ccd5331b2c4d50862f16e9abff Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sat, 29 Apr 2023 11:18:16 +0200 Subject: [PATCH 064/822] Cleanup onewire tests (#92117) --- tests/components/onewire/__init__.py | 84 +- tests/components/onewire/const.py | 1005 ++---------------- tests/components/onewire/test_config_flow.py | 22 +- 3 files changed, 98 insertions(+), 1013 deletions(-) diff --git a/tests/components/onewire/__init__.py b/tests/components/onewire/__init__.py index 9368fa8c0a87..4185337dce2e 100644 --- a/tests/components/onewire/__init__.py +++ b/tests/components/onewire/__init__.py @@ -1,94 +1,14 @@ """Tests for 1-Wire integration.""" from __future__ import annotations -from types import MappingProxyType from typing import Any from unittest.mock import MagicMock from pyownet.protocol import ProtocolError -from homeassistant.const import ( - ATTR_ENTITY_ID, - ATTR_IDENTIFIERS, - ATTR_MANUFACTURER, - ATTR_MODEL, - ATTR_NAME, - ATTR_STATE, - ATTR_VIA_DEVICE, - Platform, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceRegistry -from homeassistant.helpers.entity_registry import EntityRegistry, RegistryEntryDisabler +from homeassistant.const import Platform -from .const import ( - ATTR_DEFAULT_DISABLED, - ATTR_DEVICE_FILE, - ATTR_ENTITY_CATEGORY, - ATTR_INJECT_READS, - ATTR_UNIQUE_ID, - FIXED_ATTRIBUTES, - MOCK_OWPROXY_DEVICES, -) - - -def check_and_enable_disabled_entities( - entity_registry: EntityRegistry, expected_entities: MappingProxyType -) -> None: - """Ensure that the expected_entities are correctly disabled.""" - for expected_entity in expected_entities: - if expected_entity.get(ATTR_DEFAULT_DISABLED): - entity_id = expected_entity[ATTR_ENTITY_ID] - registry_entry = entity_registry.entities.get(entity_id) - assert registry_entry, f"{entity_id} not found in registry" - assert registry_entry.disabled - assert registry_entry.disabled_by is RegistryEntryDisabler.INTEGRATION - entity_registry.async_update_entity(entity_id, **{"disabled_by": None}) - - -def check_device_registry( - device_registry: DeviceRegistry, expected_devices: list[MappingProxyType] -) -> None: - """Ensure that the expected_devices are correctly registered.""" - for expected_device in expected_devices: - registry_entry = device_registry.async_get_device( - expected_device[ATTR_IDENTIFIERS] - ) - assert registry_entry is not None - assert registry_entry.identifiers == expected_device[ATTR_IDENTIFIERS] - assert registry_entry.manufacturer == expected_device[ATTR_MANUFACTURER] - assert registry_entry.name == expected_device[ATTR_NAME] - assert registry_entry.model == expected_device[ATTR_MODEL] - if expected_via_device := expected_device.get(ATTR_VIA_DEVICE): - assert registry_entry.via_device_id is not None - parent_entry = device_registry.async_get_device({expected_via_device}) - assert parent_entry is not None - assert registry_entry.via_device_id == parent_entry.id - else: - assert registry_entry.via_device_id is None - - -def check_entities( - hass: HomeAssistant, - entity_registry: EntityRegistry, - expected_entities: MappingProxyType, -) -> None: - """Ensure that the expected_entities are correct.""" - for expected_entity in expected_entities: - entity_id = expected_entity[ATTR_ENTITY_ID] - registry_entry = entity_registry.entities.get(entity_id) - assert registry_entry is not None, f"{entity_id} not found in registry" - assert registry_entry.entity_category == expected_entity.get( - ATTR_ENTITY_CATEGORY - ) - assert registry_entry.unique_id == expected_entity[ATTR_UNIQUE_ID] - state = hass.states.get(entity_id) - assert state.state == expected_entity[ATTR_STATE] - assert state.attributes[ATTR_DEVICE_FILE] == expected_entity.get( - ATTR_DEVICE_FILE, registry_entry.unique_id - ) - for attr in FIXED_ATTRIBUTES: - assert state.attributes.get(attr) == expected_entity.get(attr) +from .const import ATTR_INJECT_READS, MOCK_OWPROXY_DEVICES def setup_owproxy_mock_devices( diff --git a/tests/components/onewire/const.py b/tests/components/onewire/const.py index be970a971776..3da37a72459a 100644 --- a/tests/components/onewire/const.py +++ b/tests/components/onewire/const.py @@ -1,53 +1,10 @@ """Constants for 1-Wire integration.""" from pyownet.protocol import Error as ProtocolError -from homeassistant.components.binary_sensor import BinarySensorDeviceClass -from homeassistant.components.onewire.const import ( - DOMAIN, - MANUFACTURER_EDS, - MANUFACTURER_HOBBYBOARDS, - MANUFACTURER_MAXIM, - Platform, -) -from homeassistant.components.sensor import ( - ATTR_STATE_CLASS, - SensorDeviceClass, - SensorStateClass, -) -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_ENTITY_ID, - ATTR_IDENTIFIERS, - ATTR_MANUFACTURER, - ATTR_MODEL, - ATTR_NAME, - ATTR_STATE, - ATTR_UNIT_OF_MEASUREMENT, - ATTR_VIA_DEVICE, - LIGHT_LUX, - PERCENTAGE, - STATE_OFF, - STATE_ON, - STATE_UNKNOWN, - EntityCategory, - UnitOfElectricPotential, - UnitOfPressure, - UnitOfTemperature, -) +from homeassistant.components.onewire.const import Platform -ATTR_DEFAULT_DISABLED = "default_disabled" ATTR_DEVICE_FILE = "device_file" -ATTR_DEVICE_INFO = "device_info" -ATTR_ENTITY_CATEGORY = "entity_category" ATTR_INJECT_READS = "inject_reads" -ATTR_UNIQUE_ID = "unique_id" -ATTR_UNKNOWN_DEVICE = "unknown_device" - -FIXED_ATTRIBUTES = ( - ATTR_DEVICE_CLASS, - ATTR_STATE_CLASS, - ATTR_UNIT_OF_MEASUREMENT, -) MOCK_OWPROXY_DEVICES = { @@ -55,177 +12,55 @@ MOCK_OWPROXY_DEVICES = { ATTR_INJECT_READS: [ b"", # read device type ], - ATTR_UNKNOWN_DEVICE: True, }, "05.111111111111": { ATTR_INJECT_READS: [ b"DS2405", # read device type ], - ATTR_DEVICE_INFO: { - ATTR_IDENTIFIERS: {(DOMAIN, "05.111111111111")}, - ATTR_MANUFACTURER: MANUFACTURER_MAXIM, - ATTR_MODEL: "DS2405", - ATTR_NAME: "05.111111111111", - }, Platform.SWITCH: [ - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "switch.05_111111111111_programmed_input_output", - ATTR_INJECT_READS: b" 1", - ATTR_STATE: STATE_ON, - ATTR_UNIQUE_ID: "/05.111111111111/PIO", - }, + {ATTR_INJECT_READS: b" 1"}, ], }, "10.111111111111": { ATTR_INJECT_READS: [ b"DS18S20", # read device type ], - ATTR_DEVICE_INFO: { - ATTR_IDENTIFIERS: {(DOMAIN, "10.111111111111")}, - ATTR_MANUFACTURER: MANUFACTURER_MAXIM, - ATTR_MODEL: "DS18S20", - ATTR_NAME: "10.111111111111", - }, Platform.SENSOR: [ - { - ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, - ATTR_ENTITY_ID: "sensor.10_111111111111_temperature", - ATTR_INJECT_READS: b" 25.123", - ATTR_STATE: "25.1", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/10.111111111111/temperature", - ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, - }, + {ATTR_INJECT_READS: b" 25.123"}, ], }, "12.111111111111": { ATTR_INJECT_READS: [ b"DS2406", # read device type ], - ATTR_DEVICE_INFO: { - ATTR_IDENTIFIERS: {(DOMAIN, "12.111111111111")}, - ATTR_MANUFACTURER: MANUFACTURER_MAXIM, - ATTR_MODEL: "DS2406", - ATTR_NAME: "12.111111111111", - }, Platform.BINARY_SENSOR: [ - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "binary_sensor.12_111111111111_sensed_a", - ATTR_INJECT_READS: b" 1", - ATTR_STATE: STATE_ON, - ATTR_UNIQUE_ID: "/12.111111111111/sensed.A", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "binary_sensor.12_111111111111_sensed_b", - ATTR_INJECT_READS: b" 0", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "/12.111111111111/sensed.B", - }, + {ATTR_INJECT_READS: b" 1"}, + {ATTR_INJECT_READS: b" 0"}, ], Platform.SENSOR: [ - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, - ATTR_ENTITY_ID: "sensor.12_111111111111_temperature", - ATTR_INJECT_READS: b" 25.123", - ATTR_STATE: "25.1", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/12.111111111111/TAI8570/temperature", - ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: SensorDeviceClass.PRESSURE, - ATTR_ENTITY_ID: "sensor.12_111111111111_pressure", - ATTR_INJECT_READS: b" 1025.123", - ATTR_STATE: "1025.1", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/12.111111111111/TAI8570/pressure", - ATTR_UNIT_OF_MEASUREMENT: UnitOfPressure.MBAR, - }, + {ATTR_INJECT_READS: b" 25.123"}, + {ATTR_INJECT_READS: b" 1025.123"}, ], Platform.SWITCH: [ - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "switch.12_111111111111_programmed_input_output_a", - ATTR_INJECT_READS: b" 1", - ATTR_STATE: STATE_ON, - ATTR_UNIQUE_ID: "/12.111111111111/PIO.A", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "switch.12_111111111111_programmed_input_output_b", - ATTR_INJECT_READS: b" 0", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "/12.111111111111/PIO.B", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "switch.12_111111111111_latch_a", - ATTR_INJECT_READS: b" 1", - ATTR_STATE: STATE_ON, - ATTR_UNIQUE_ID: "/12.111111111111/latch.A", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "switch.12_111111111111_latch_b", - ATTR_INJECT_READS: b" 0", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "/12.111111111111/latch.B", - }, + {ATTR_INJECT_READS: b" 1"}, + {ATTR_INJECT_READS: b" 0"}, + {ATTR_INJECT_READS: b" 1"}, + {ATTR_INJECT_READS: b" 0"}, ], }, "1D.111111111111": { ATTR_INJECT_READS: [ b"DS2423", # read device type ], - ATTR_DEVICE_INFO: { - ATTR_IDENTIFIERS: {(DOMAIN, "1D.111111111111")}, - ATTR_MANUFACTURER: MANUFACTURER_MAXIM, - ATTR_MODEL: "DS2423", - ATTR_NAME: "1D.111111111111", - }, Platform.SENSOR: [ - { - ATTR_ENTITY_ID: "sensor.1d_111111111111_counter_a", - ATTR_INJECT_READS: b" 251123", - ATTR_STATE: "251123", - ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, - ATTR_UNIQUE_ID: "/1D.111111111111/counter.A", - ATTR_UNIT_OF_MEASUREMENT: "count", - }, - { - ATTR_ENTITY_ID: "sensor.1d_111111111111_counter_b", - ATTR_INJECT_READS: b" 248125", - ATTR_STATE: "248125", - ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, - ATTR_UNIQUE_ID: "/1D.111111111111/counter.B", - ATTR_UNIT_OF_MEASUREMENT: "count", - }, + {ATTR_INJECT_READS: b" 251123"}, + {ATTR_INJECT_READS: b" 248125"}, ], }, "1F.111111111111": { ATTR_INJECT_READS: [ b"DS2409", # read device type ], - ATTR_DEVICE_INFO: [ - { - ATTR_IDENTIFIERS: {(DOMAIN, "1F.111111111111")}, - ATTR_MANUFACTURER: MANUFACTURER_MAXIM, - ATTR_MODEL: "DS2409", - ATTR_NAME: "1F.111111111111", - }, - { - ATTR_IDENTIFIERS: {(DOMAIN, "1D.111111111111")}, - ATTR_MANUFACTURER: MANUFACTURER_MAXIM, - ATTR_MODEL: "DS2423", - ATTR_NAME: "1D.111111111111", - ATTR_VIA_DEVICE: (DOMAIN, "1F.111111111111"), - }, - ], "branches": { "aux": {}, "main": { @@ -236,21 +71,11 @@ MOCK_OWPROXY_DEVICES = { Platform.SENSOR: [ { ATTR_DEVICE_FILE: "/1F.111111111111/main/1D.111111111111/counter.A", - ATTR_ENTITY_ID: "sensor.1d_111111111111_counter_a", ATTR_INJECT_READS: b" 251123", - ATTR_STATE: "251123", - ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, - ATTR_UNIQUE_ID: "/1D.111111111111/counter.A", - ATTR_UNIT_OF_MEASUREMENT: "count", }, { ATTR_DEVICE_FILE: "/1F.111111111111/main/1D.111111111111/counter.B", - ATTR_ENTITY_ID: "sensor.1d_111111111111_counter_b", ATTR_INJECT_READS: b" 248125", - ATTR_STATE: "248125", - ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, - ATTR_UNIQUE_ID: "/1D.111111111111/counter.B", - ATTR_UNIT_OF_MEASUREMENT: "count", }, ], }, @@ -261,21 +86,9 @@ MOCK_OWPROXY_DEVICES = { ATTR_INJECT_READS: [ b"DS1822", # read device type ], - ATTR_DEVICE_INFO: { - ATTR_IDENTIFIERS: {(DOMAIN, "22.111111111111")}, - ATTR_MANUFACTURER: MANUFACTURER_MAXIM, - ATTR_MODEL: "DS1822", - ATTR_NAME: "22.111111111111", - }, Platform.SENSOR: [ { - ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, - ATTR_ENTITY_ID: "sensor.22_111111111111_temperature", ATTR_INJECT_READS: ProtocolError, - ATTR_STATE: STATE_UNKNOWN, - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/22.111111111111/temperature", - ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, }, ], }, @@ -283,154 +96,31 @@ MOCK_OWPROXY_DEVICES = { ATTR_INJECT_READS: [ b"DS2438", # read device type ], - ATTR_DEVICE_INFO: { - ATTR_IDENTIFIERS: {(DOMAIN, "26.111111111111")}, - ATTR_MANUFACTURER: MANUFACTURER_MAXIM, - ATTR_MODEL: "DS2438", - ATTR_NAME: "26.111111111111", - }, Platform.SENSOR: [ + {ATTR_INJECT_READS: b" 25.123"}, + {ATTR_INJECT_READS: b" 72.7563"}, + {ATTR_INJECT_READS: b" 73.7563"}, + {ATTR_INJECT_READS: b" 74.7563"}, + {ATTR_INJECT_READS: b" 75.7563"}, { - ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, - ATTR_ENTITY_ID: "sensor.26_111111111111_temperature", - ATTR_INJECT_READS: b" 25.123", - ATTR_STATE: "25.1", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/26.111111111111/temperature", - ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY, - ATTR_ENTITY_ID: "sensor.26_111111111111_humidity", - ATTR_INJECT_READS: b" 72.7563", - ATTR_STATE: "72.8", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/26.111111111111/humidity", - ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY, - ATTR_ENTITY_ID: "sensor.26_111111111111_hih3600_humidity", - ATTR_INJECT_READS: b" 73.7563", - ATTR_STATE: "73.8", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/26.111111111111/HIH3600/humidity", - ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY, - ATTR_ENTITY_ID: "sensor.26_111111111111_hih4000_humidity", - ATTR_INJECT_READS: b" 74.7563", - ATTR_STATE: "74.8", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/26.111111111111/HIH4000/humidity", - ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY, - ATTR_ENTITY_ID: "sensor.26_111111111111_hih5030_humidity", - ATTR_INJECT_READS: b" 75.7563", - ATTR_STATE: "75.8", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/26.111111111111/HIH5030/humidity", - ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY, - ATTR_ENTITY_ID: "sensor.26_111111111111_htm1735_humidity", ATTR_INJECT_READS: ProtocolError, - ATTR_STATE: STATE_UNKNOWN, - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/26.111111111111/HTM1735/humidity", - ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: SensorDeviceClass.PRESSURE, - ATTR_ENTITY_ID: "sensor.26_111111111111_pressure", - ATTR_INJECT_READS: b" 969.265", - ATTR_STATE: "969.3", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/26.111111111111/B1-R1-A/pressure", - ATTR_UNIT_OF_MEASUREMENT: UnitOfPressure.MBAR, - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: SensorDeviceClass.ILLUMINANCE, - ATTR_ENTITY_ID: "sensor.26_111111111111_illuminance", - ATTR_INJECT_READS: b" 65.8839", - ATTR_STATE: "65.9", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/26.111111111111/S3-R1-A/illuminance", - ATTR_UNIT_OF_MEASUREMENT: LIGHT_LUX, - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: SensorDeviceClass.VOLTAGE, - ATTR_ENTITY_ID: "sensor.26_111111111111_vad_voltage", - ATTR_INJECT_READS: b" 2.97", - ATTR_STATE: "3.0", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/26.111111111111/VAD", - ATTR_UNIT_OF_MEASUREMENT: UnitOfElectricPotential.VOLT, - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: SensorDeviceClass.VOLTAGE, - ATTR_ENTITY_ID: "sensor.26_111111111111_vdd_voltage", - ATTR_INJECT_READS: b" 4.74", - ATTR_STATE: "4.7", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/26.111111111111/VDD", - ATTR_UNIT_OF_MEASUREMENT: UnitOfElectricPotential.VOLT, - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: SensorDeviceClass.VOLTAGE, - ATTR_ENTITY_ID: "sensor.26_111111111111_vis_voltage_difference", - ATTR_INJECT_READS: b" 0.12", - ATTR_STATE: "0.1", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/26.111111111111/vis", - ATTR_UNIT_OF_MEASUREMENT: UnitOfElectricPotential.VOLT, }, + {ATTR_INJECT_READS: b" 969.265"}, + {ATTR_INJECT_READS: b" 65.8839"}, + {ATTR_INJECT_READS: b" 2.97"}, + {ATTR_INJECT_READS: b" 4.74"}, + {ATTR_INJECT_READS: b" 0.12"}, ], Platform.SWITCH: [ - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_CATEGORY: EntityCategory.CONFIG, - ATTR_ENTITY_ID: "switch.26_111111111111_current_a_d_control", - ATTR_INJECT_READS: b" 1", - ATTR_STATE: STATE_ON, - ATTR_UNIQUE_ID: "/26.111111111111/IAD", - }, + {ATTR_INJECT_READS: b" 1"}, ], }, "28.111111111111": { ATTR_INJECT_READS: [ b"DS18B20", # read device type ], - ATTR_DEVICE_INFO: { - ATTR_IDENTIFIERS: {(DOMAIN, "28.111111111111")}, - ATTR_MANUFACTURER: MANUFACTURER_MAXIM, - ATTR_MODEL: "DS18B20", - ATTR_NAME: "28.111111111111", - }, Platform.SENSOR: [ - { - ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, - ATTR_ENTITY_ID: "sensor.28_111111111111_temperature", - ATTR_INJECT_READS: b" 26.984", - ATTR_STATE: "27.0", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/28.111111111111/temperature", - ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, - }, + {ATTR_INJECT_READS: b" 26.984"}, ], }, "28.222222222222": { @@ -438,22 +128,10 @@ MOCK_OWPROXY_DEVICES = { ATTR_INJECT_READS: [ b"DS18B20", # read device type ], - ATTR_DEVICE_INFO: { - ATTR_IDENTIFIERS: {(DOMAIN, "28.222222222222")}, - ATTR_MANUFACTURER: MANUFACTURER_MAXIM, - ATTR_MODEL: "DS18B20", - ATTR_NAME: "28.222222222222", - }, Platform.SENSOR: [ { - ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, ATTR_DEVICE_FILE: "/28.222222222222/temperature9", - ATTR_ENTITY_ID: "sensor.28_222222222222_temperature", ATTR_INJECT_READS: b" 26.984", - ATTR_STATE: "27.0", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/28.222222222222/temperature", - ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, }, ], }, @@ -462,22 +140,10 @@ MOCK_OWPROXY_DEVICES = { ATTR_INJECT_READS: [ b"DS18B20", # read device type ], - ATTR_DEVICE_INFO: { - ATTR_IDENTIFIERS: {(DOMAIN, "28.222222222223")}, - ATTR_MANUFACTURER: MANUFACTURER_MAXIM, - ATTR_MODEL: "DS18B20", - ATTR_NAME: "28.222222222223", - }, Platform.SENSOR: [ { - ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, ATTR_DEVICE_FILE: "/28.222222222223/temperature", - ATTR_ENTITY_ID: "sensor.28_222222222223_temperature", ATTR_INJECT_READS: b" 26.984", - ATTR_STATE: "27.0", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/28.222222222223/temperature", - ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, }, ], }, @@ -485,363 +151,86 @@ MOCK_OWPROXY_DEVICES = { ATTR_INJECT_READS: [ b"DS2408", # read device type ], - ATTR_DEVICE_INFO: { - ATTR_IDENTIFIERS: {(DOMAIN, "29.111111111111")}, - ATTR_MANUFACTURER: MANUFACTURER_MAXIM, - ATTR_MODEL: "DS2408", - ATTR_NAME: "29.111111111111", - }, Platform.BINARY_SENSOR: [ - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "binary_sensor.29_111111111111_sensed_0", - ATTR_INJECT_READS: b" 1", - ATTR_STATE: STATE_ON, - ATTR_UNIQUE_ID: "/29.111111111111/sensed.0", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "binary_sensor.29_111111111111_sensed_1", - ATTR_INJECT_READS: b" 0", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "/29.111111111111/sensed.1", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "binary_sensor.29_111111111111_sensed_2", - ATTR_INJECT_READS: b" 0", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "/29.111111111111/sensed.2", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "binary_sensor.29_111111111111_sensed_3", - ATTR_INJECT_READS: b" 0", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "/29.111111111111/sensed.3", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "binary_sensor.29_111111111111_sensed_4", - ATTR_INJECT_READS: b" 0", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "/29.111111111111/sensed.4", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "binary_sensor.29_111111111111_sensed_5", - ATTR_INJECT_READS: b" 0", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "/29.111111111111/sensed.5", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "binary_sensor.29_111111111111_sensed_6", - ATTR_INJECT_READS: b" 0", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "/29.111111111111/sensed.6", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "binary_sensor.29_111111111111_sensed_7", - ATTR_INJECT_READS: b" 0", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "/29.111111111111/sensed.7", - }, + {ATTR_INJECT_READS: b" 1"}, + {ATTR_INJECT_READS: b" 0"}, + {ATTR_INJECT_READS: b" 0"}, + {ATTR_INJECT_READS: b" 0"}, + {ATTR_INJECT_READS: b" 0"}, + {ATTR_INJECT_READS: b" 0"}, + {ATTR_INJECT_READS: b" 0"}, + {ATTR_INJECT_READS: b" 0"}, ], Platform.SWITCH: [ - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "switch.29_111111111111_programmed_input_output_0", - ATTR_INJECT_READS: b" 1", - ATTR_STATE: STATE_ON, - ATTR_UNIQUE_ID: "/29.111111111111/PIO.0", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "switch.29_111111111111_programmed_input_output_1", - ATTR_INJECT_READS: b" 0", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "/29.111111111111/PIO.1", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "switch.29_111111111111_programmed_input_output_2", - ATTR_INJECT_READS: b" 1", - ATTR_STATE: STATE_ON, - ATTR_UNIQUE_ID: "/29.111111111111/PIO.2", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "switch.29_111111111111_programmed_input_output_3", - ATTR_INJECT_READS: b" 0", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "/29.111111111111/PIO.3", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "switch.29_111111111111_programmed_input_output_4", - ATTR_INJECT_READS: b" 1", - ATTR_STATE: STATE_ON, - ATTR_UNIQUE_ID: "/29.111111111111/PIO.4", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "switch.29_111111111111_programmed_input_output_5", - ATTR_INJECT_READS: b" 0", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "/29.111111111111/PIO.5", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "switch.29_111111111111_programmed_input_output_6", - ATTR_INJECT_READS: b" 1", - ATTR_STATE: STATE_ON, - ATTR_UNIQUE_ID: "/29.111111111111/PIO.6", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "switch.29_111111111111_programmed_input_output_7", - ATTR_INJECT_READS: b" 0", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "/29.111111111111/PIO.7", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "switch.29_111111111111_latch_0", - ATTR_INJECT_READS: b" 1", - ATTR_STATE: STATE_ON, - ATTR_UNIQUE_ID: "/29.111111111111/latch.0", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "switch.29_111111111111_latch_1", - ATTR_INJECT_READS: b" 0", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "/29.111111111111/latch.1", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "switch.29_111111111111_latch_2", - ATTR_INJECT_READS: b" 1", - ATTR_STATE: STATE_ON, - ATTR_UNIQUE_ID: "/29.111111111111/latch.2", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "switch.29_111111111111_latch_3", - ATTR_INJECT_READS: b" 0", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "/29.111111111111/latch.3", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "switch.29_111111111111_latch_4", - ATTR_INJECT_READS: b" 1", - ATTR_STATE: STATE_ON, - ATTR_UNIQUE_ID: "/29.111111111111/latch.4", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "switch.29_111111111111_latch_5", - ATTR_INJECT_READS: b" 0", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "/29.111111111111/latch.5", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "switch.29_111111111111_latch_6", - ATTR_INJECT_READS: b" 1", - ATTR_STATE: STATE_ON, - ATTR_UNIQUE_ID: "/29.111111111111/latch.6", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "switch.29_111111111111_latch_7", - ATTR_INJECT_READS: b" 0", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "/29.111111111111/latch.7", - }, + {ATTR_INJECT_READS: b" 1"}, + {ATTR_INJECT_READS: b" 0"}, + {ATTR_INJECT_READS: b" 1"}, + {ATTR_INJECT_READS: b" 0"}, + {ATTR_INJECT_READS: b" 1"}, + {ATTR_INJECT_READS: b" 0"}, + {ATTR_INJECT_READS: b" 1"}, + {ATTR_INJECT_READS: b" 0"}, + {ATTR_INJECT_READS: b" 1"}, + {ATTR_INJECT_READS: b" 0"}, + {ATTR_INJECT_READS: b" 1"}, + {ATTR_INJECT_READS: b" 0"}, + {ATTR_INJECT_READS: b" 1"}, + {ATTR_INJECT_READS: b" 0"}, + {ATTR_INJECT_READS: b" 1"}, + {ATTR_INJECT_READS: b" 0"}, ], }, "30.111111111111": { ATTR_INJECT_READS: [ b"DS2760", # read device type ], - ATTR_DEVICE_INFO: { - ATTR_IDENTIFIERS: {(DOMAIN, "30.111111111111")}, - ATTR_MANUFACTURER: MANUFACTURER_MAXIM, - ATTR_MODEL: "DS2760", - ATTR_NAME: "30.111111111111", - }, Platform.SENSOR: [ + {ATTR_INJECT_READS: b" 26.984"}, { - ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, - ATTR_ENTITY_ID: "sensor.30_111111111111_temperature", - ATTR_INJECT_READS: b" 26.984", - ATTR_STATE: "27.0", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/30.111111111111/temperature", - ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, ATTR_DEVICE_FILE: "/30.111111111111/typeK/temperature", - ATTR_ENTITY_ID: "sensor.30_111111111111_thermocouple_k_temperature", ATTR_INJECT_READS: b" 173.7563", - ATTR_STATE: "173.8", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/30.111111111111/typeX/temperature", - ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: SensorDeviceClass.VOLTAGE, - ATTR_ENTITY_ID: "sensor.30_111111111111_voltage", - ATTR_INJECT_READS: b" 2.97", - ATTR_STATE: "3.0", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/30.111111111111/volt", - ATTR_UNIT_OF_MEASUREMENT: UnitOfElectricPotential.VOLT, - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: SensorDeviceClass.VOLTAGE, - ATTR_ENTITY_ID: "sensor.30_111111111111_vis_voltage_gradient", - ATTR_INJECT_READS: b" 0.12", - ATTR_STATE: "0.1", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/30.111111111111/vis", - ATTR_UNIT_OF_MEASUREMENT: UnitOfElectricPotential.VOLT, }, + {ATTR_INJECT_READS: b" 2.97"}, + {ATTR_INJECT_READS: b" 0.12"}, ], }, "3A.111111111111": { ATTR_INJECT_READS: [ b"DS2413", # read device type ], - ATTR_DEVICE_INFO: { - ATTR_IDENTIFIERS: {(DOMAIN, "3A.111111111111")}, - ATTR_MANUFACTURER: MANUFACTURER_MAXIM, - ATTR_MODEL: "DS2413", - ATTR_NAME: "3A.111111111111", - }, Platform.BINARY_SENSOR: [ - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "binary_sensor.3a_111111111111_sensed_a", - ATTR_INJECT_READS: b" 1", - ATTR_STATE: STATE_ON, - ATTR_UNIQUE_ID: "/3A.111111111111/sensed.A", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "binary_sensor.3a_111111111111_sensed_b", - ATTR_INJECT_READS: b" 0", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "/3A.111111111111/sensed.B", - }, + {ATTR_INJECT_READS: b" 1"}, + {ATTR_INJECT_READS: b" 0"}, ], Platform.SWITCH: [ - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "switch.3a_111111111111_programmed_input_output_a", - ATTR_INJECT_READS: b" 1", - ATTR_STATE: STATE_ON, - ATTR_UNIQUE_ID: "/3A.111111111111/PIO.A", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "switch.3a_111111111111_programmed_input_output_b", - ATTR_INJECT_READS: b" 0", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "/3A.111111111111/PIO.B", - }, + {ATTR_INJECT_READS: b" 1"}, + {ATTR_INJECT_READS: b" 0"}, ], }, "3B.111111111111": { ATTR_INJECT_READS: [ b"DS1825", # read device type ], - ATTR_DEVICE_INFO: { - ATTR_IDENTIFIERS: {(DOMAIN, "3B.111111111111")}, - ATTR_MANUFACTURER: MANUFACTURER_MAXIM, - ATTR_MODEL: "DS1825", - ATTR_NAME: "3B.111111111111", - }, Platform.SENSOR: [ - { - ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, - ATTR_ENTITY_ID: "sensor.3b_111111111111_temperature", - ATTR_INJECT_READS: b" 28.243", - ATTR_STATE: "28.2", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/3B.111111111111/temperature", - ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, - }, + {ATTR_INJECT_READS: b" 28.243"}, ], }, "42.111111111111": { ATTR_INJECT_READS: [ b"DS28EA00", # read device type ], - ATTR_DEVICE_INFO: { - ATTR_IDENTIFIERS: {(DOMAIN, "42.111111111111")}, - ATTR_MANUFACTURER: MANUFACTURER_MAXIM, - ATTR_MODEL: "DS28EA00", - ATTR_NAME: "42.111111111111", - }, Platform.SENSOR: [ - { - ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, - ATTR_ENTITY_ID: "sensor.42_111111111111_temperature", - ATTR_INJECT_READS: b" 29.123", - ATTR_STATE: "29.1", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/42.111111111111/temperature", - ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, - }, + {ATTR_INJECT_READS: b" 29.123"}, ], }, "EF.111111111111": { ATTR_INJECT_READS: [ b"HobbyBoards_EF", # read type ], - ATTR_DEVICE_INFO: { - ATTR_IDENTIFIERS: {(DOMAIN, "EF.111111111111")}, - ATTR_MANUFACTURER: MANUFACTURER_HOBBYBOARDS, - ATTR_MODEL: "HobbyBoards_EF", - ATTR_NAME: "EF.111111111111", - }, Platform.SENSOR: [ - { - ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY, - ATTR_ENTITY_ID: "sensor.ef_111111111111_humidity", - ATTR_INJECT_READS: b" 67.745", - ATTR_STATE: "67.7", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/EF.111111111111/humidity/humidity_corrected", - ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY, - ATTR_ENTITY_ID: "sensor.ef_111111111111_raw_humidity", - ATTR_INJECT_READS: b" 65.541", - ATTR_STATE: "65.5", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/EF.111111111111/humidity/humidity_raw", - ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, - ATTR_ENTITY_ID: "sensor.ef_111111111111_temperature", - ATTR_INJECT_READS: b" 25.123", - ATTR_STATE: "25.1", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/EF.111111111111/humidity/temperature", - ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, - }, + {ATTR_INJECT_READS: b" 67.745"}, + {ATTR_INJECT_READS: b" 65.541"}, + {ATTR_INJECT_READS: b" 25.123"}, ], }, "EF.111111111112": { @@ -852,198 +241,38 @@ MOCK_OWPROXY_DEVICES = { b" 0", # read is_leaf_2 b" 0", # read is_leaf_3 ], - ATTR_DEVICE_INFO: { - ATTR_IDENTIFIERS: {(DOMAIN, "EF.111111111112")}, - ATTR_MANUFACTURER: MANUFACTURER_HOBBYBOARDS, - ATTR_MODEL: "HB_MOISTURE_METER", - ATTR_NAME: "EF.111111111112", - }, Platform.SENSOR: [ - { - ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY, - ATTR_ENTITY_ID: "sensor.ef_111111111112_wetness_0", - ATTR_INJECT_READS: b" 41.745", - ATTR_STATE: "41.7", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/EF.111111111112/moisture/sensor.0", - ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY, - ATTR_ENTITY_ID: "sensor.ef_111111111112_wetness_1", - ATTR_INJECT_READS: b" 42.541", - ATTR_STATE: "42.5", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/EF.111111111112/moisture/sensor.1", - ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.PRESSURE, - ATTR_ENTITY_ID: "sensor.ef_111111111112_moisture_2", - ATTR_INJECT_READS: b" 43.123", - ATTR_STATE: "43.1", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/EF.111111111112/moisture/sensor.2", - ATTR_UNIT_OF_MEASUREMENT: UnitOfPressure.CBAR, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.PRESSURE, - ATTR_ENTITY_ID: "sensor.ef_111111111112_moisture_3", - ATTR_INJECT_READS: b" 44.123", - ATTR_STATE: "44.1", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/EF.111111111112/moisture/sensor.3", - ATTR_UNIT_OF_MEASUREMENT: UnitOfPressure.CBAR, - }, + {ATTR_INJECT_READS: b" 41.745"}, + {ATTR_INJECT_READS: b" 42.541"}, + {ATTR_INJECT_READS: b" 43.123"}, + {ATTR_INJECT_READS: b" 44.123"}, ], Platform.SWITCH: [ - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_CATEGORY: EntityCategory.CONFIG, - ATTR_ENTITY_ID: "switch.ef_111111111112_leaf_sensor_0", - ATTR_INJECT_READS: b"1", - ATTR_STATE: STATE_ON, - ATTR_UNIQUE_ID: "/EF.111111111112/moisture/is_leaf.0", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_CATEGORY: EntityCategory.CONFIG, - ATTR_ENTITY_ID: "switch.ef_111111111112_leaf_sensor_1", - ATTR_INJECT_READS: b"1", - ATTR_STATE: STATE_ON, - ATTR_UNIQUE_ID: "/EF.111111111112/moisture/is_leaf.1", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_CATEGORY: EntityCategory.CONFIG, - ATTR_ENTITY_ID: "switch.ef_111111111112_leaf_sensor_2", - ATTR_INJECT_READS: b"0", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "/EF.111111111112/moisture/is_leaf.2", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_CATEGORY: EntityCategory.CONFIG, - ATTR_ENTITY_ID: "switch.ef_111111111112_leaf_sensor_3", - ATTR_INJECT_READS: b"0", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "/EF.111111111112/moisture/is_leaf.3", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_CATEGORY: EntityCategory.CONFIG, - ATTR_ENTITY_ID: "switch.ef_111111111112_moisture_sensor_0", - ATTR_INJECT_READS: b"1", - ATTR_STATE: STATE_ON, - ATTR_UNIQUE_ID: "/EF.111111111112/moisture/is_moisture.0", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_CATEGORY: EntityCategory.CONFIG, - ATTR_ENTITY_ID: "switch.ef_111111111112_moisture_sensor_1", - ATTR_INJECT_READS: b"1", - ATTR_STATE: STATE_ON, - ATTR_UNIQUE_ID: "/EF.111111111112/moisture/is_moisture.1", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_CATEGORY: EntityCategory.CONFIG, - ATTR_ENTITY_ID: "switch.ef_111111111112_moisture_sensor_2", - ATTR_INJECT_READS: b"0", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "/EF.111111111112/moisture/is_moisture.2", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_CATEGORY: EntityCategory.CONFIG, - ATTR_ENTITY_ID: "switch.ef_111111111112_moisture_sensor_3", - ATTR_INJECT_READS: b"0", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "/EF.111111111112/moisture/is_moisture.3", - }, + {ATTR_INJECT_READS: b"1"}, + {ATTR_INJECT_READS: b"1"}, + {ATTR_INJECT_READS: b"0"}, + {ATTR_INJECT_READS: b"0"}, + {ATTR_INJECT_READS: b"1"}, + {ATTR_INJECT_READS: b"1"}, + {ATTR_INJECT_READS: b"0"}, + {ATTR_INJECT_READS: b"0"}, ], }, "EF.111111111113": { ATTR_INJECT_READS: [ b"HB_HUB", # read type ], - ATTR_DEVICE_INFO: { - ATTR_IDENTIFIERS: {(DOMAIN, "EF.111111111113")}, - ATTR_MANUFACTURER: MANUFACTURER_HOBBYBOARDS, - ATTR_MODEL: "HB_HUB", - ATTR_NAME: "EF.111111111113", - }, Platform.BINARY_SENSOR: [ - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: BinarySensorDeviceClass.PROBLEM, - ATTR_ENTITY_CATEGORY: EntityCategory.DIAGNOSTIC, - ATTR_ENTITY_ID: "binary_sensor.ef_111111111113_hub_short_on_branch_0", - ATTR_INJECT_READS: b"1", - ATTR_STATE: STATE_ON, - ATTR_UNIQUE_ID: "/EF.111111111113/hub/short.0", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: BinarySensorDeviceClass.PROBLEM, - ATTR_ENTITY_CATEGORY: EntityCategory.DIAGNOSTIC, - ATTR_ENTITY_ID: "binary_sensor.ef_111111111113_hub_short_on_branch_1", - ATTR_INJECT_READS: b"0", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "/EF.111111111113/hub/short.1", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: BinarySensorDeviceClass.PROBLEM, - ATTR_ENTITY_CATEGORY: EntityCategory.DIAGNOSTIC, - ATTR_ENTITY_ID: "binary_sensor.ef_111111111113_hub_short_on_branch_2", - ATTR_INJECT_READS: b"1", - ATTR_STATE: STATE_ON, - ATTR_UNIQUE_ID: "/EF.111111111113/hub/short.2", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: BinarySensorDeviceClass.PROBLEM, - ATTR_ENTITY_CATEGORY: EntityCategory.DIAGNOSTIC, - ATTR_ENTITY_ID: "binary_sensor.ef_111111111113_hub_short_on_branch_3", - ATTR_INJECT_READS: b"0", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "/EF.111111111113/hub/short.3", - }, + {ATTR_INJECT_READS: b"1"}, + {ATTR_INJECT_READS: b"0"}, + {ATTR_INJECT_READS: b"1"}, + {ATTR_INJECT_READS: b"0"}, ], Platform.SWITCH: [ - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_CATEGORY: EntityCategory.CONFIG, - ATTR_ENTITY_ID: "switch.ef_111111111113_hub_branch_0", - ATTR_INJECT_READS: b"1", - ATTR_STATE: STATE_ON, - ATTR_UNIQUE_ID: "/EF.111111111113/hub/branch.0", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_CATEGORY: EntityCategory.CONFIG, - ATTR_ENTITY_ID: "switch.ef_111111111113_hub_branch_1", - ATTR_INJECT_READS: b"0", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "/EF.111111111113/hub/branch.1", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_CATEGORY: EntityCategory.CONFIG, - ATTR_ENTITY_ID: "switch.ef_111111111113_hub_branch_2", - ATTR_INJECT_READS: b"1", - ATTR_STATE: STATE_ON, - ATTR_UNIQUE_ID: "/EF.111111111113/hub/branch.2", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_CATEGORY: EntityCategory.CONFIG, - ATTR_ENTITY_ID: "switch.ef_111111111113_hub_branch_3", - ATTR_INJECT_READS: b"0", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "/EF.111111111113/hub/branch.3", - }, + {ATTR_INJECT_READS: b"1"}, + {ATTR_INJECT_READS: b"0"}, + {ATTR_INJECT_READS: b"1"}, + {ATTR_INJECT_READS: b"0"}, ], }, "7E.111111111111": { @@ -1051,49 +280,11 @@ MOCK_OWPROXY_DEVICES = { b"EDS", # read type b"EDS0068", # read device_type - note EDS specific ], - ATTR_DEVICE_INFO: { - ATTR_IDENTIFIERS: {(DOMAIN, "7E.111111111111")}, - ATTR_MANUFACTURER: MANUFACTURER_EDS, - ATTR_MODEL: "EDS0068", - ATTR_NAME: "7E.111111111111", - }, Platform.SENSOR: [ - { - ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, - ATTR_ENTITY_ID: "sensor.7e_111111111111_temperature", - ATTR_INJECT_READS: b" 13.9375", - ATTR_STATE: "13.9", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/7E.111111111111/EDS0068/temperature", - ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.PRESSURE, - ATTR_ENTITY_ID: "sensor.7e_111111111111_pressure", - ATTR_INJECT_READS: b" 1012.21", - ATTR_STATE: "1012.2", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/7E.111111111111/EDS0068/pressure", - ATTR_UNIT_OF_MEASUREMENT: UnitOfPressure.MBAR, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.ILLUMINANCE, - ATTR_ENTITY_ID: "sensor.7e_111111111111_illuminance", - ATTR_INJECT_READS: b" 65.8839", - ATTR_STATE: "65.9", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/7E.111111111111/EDS0068/light", - ATTR_UNIT_OF_MEASUREMENT: LIGHT_LUX, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY, - ATTR_ENTITY_ID: "sensor.7e_111111111111_humidity", - ATTR_INJECT_READS: b" 41.375", - ATTR_STATE: "41.4", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/7E.111111111111/EDS0068/humidity", - ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, - }, + {ATTR_INJECT_READS: b" 13.9375"}, + {ATTR_INJECT_READS: b" 1012.21"}, + {ATTR_INJECT_READS: b" 65.8839"}, + {ATTR_INJECT_READS: b" 41.375"}, ], }, "7E.222222222222": { @@ -1101,31 +292,9 @@ MOCK_OWPROXY_DEVICES = { b"EDS", # read type b"EDS0066", # read device_type - note EDS specific ], - ATTR_DEVICE_INFO: { - ATTR_IDENTIFIERS: {(DOMAIN, "7E.222222222222")}, - ATTR_MANUFACTURER: MANUFACTURER_EDS, - ATTR_MODEL: "EDS0066", - ATTR_NAME: "7E.222222222222", - }, Platform.SENSOR: [ - { - ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, - ATTR_ENTITY_ID: "sensor.7e_222222222222_temperature", - ATTR_INJECT_READS: b" 13.9375", - ATTR_STATE: "13.9", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/7E.222222222222/EDS0066/temperature", - ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.PRESSURE, - ATTR_ENTITY_ID: "sensor.7e_222222222222_pressure", - ATTR_INJECT_READS: b" 1012.21", - ATTR_STATE: "1012.2", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/7E.222222222222/EDS0066/pressure", - ATTR_UNIT_OF_MEASUREMENT: UnitOfPressure.MBAR, - }, + {ATTR_INJECT_READS: b" 13.9375"}, + {ATTR_INJECT_READS: b" 1012.21"}, ], }, } diff --git a/tests/components/onewire/test_config_flow.py b/tests/components/onewire/test_config_flow.py index d69f9a93200e..2ba52f31c941 100644 --- a/tests/components/onewire/test_config_flow.py +++ b/tests/components/onewire/test_config_flow.py @@ -8,15 +8,13 @@ from homeassistant.components.onewire.const import ( DOMAIN, INPUT_ENTRY_CLEAR_OPTIONS, INPUT_ENTRY_DEVICE_SELECTION, + MANUFACTURER_MAXIM, ) from homeassistant.config_entries import SOURCE_USER, ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.config_validation import ensure_list - -from .const import MOCK_OWPROXY_DEVICES pytestmark = pytest.mark.usefixtures("mock_setup_entry") @@ -26,16 +24,14 @@ async def filled_device_registry( hass: HomeAssistant, config_entry: ConfigEntry, device_registry: dr.DeviceRegistry ) -> dr.DeviceRegistry: """Fill device registry with mock devices.""" - for device_details in MOCK_OWPROXY_DEVICES.values(): - if infos := device_details.get("device_info"): - for info in ensure_list(infos): - device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - identifiers=info["identifiers"], - manufacturer=info["manufacturer"], - model=info["model"], - name=info["name"], - ) + for key in ("28.111111111111", "28.222222222222", "28.222222222223"): + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, key)}, + manufacturer=MANUFACTURER_MAXIM, + model="DS18B20", + name=key, + ) return device_registry From 44186bb731b0ec04f98ca58cebe95faf91aa449e Mon Sep 17 00:00:00 2001 From: Rajeevan Date: Sat, 29 Apr 2023 11:33:43 +0200 Subject: [PATCH 065/822] Fix solaredge-local protobuf exception (#92090) --- homeassistant/components/solaredge_local/manifest.json | 2 +- homeassistant/components/solaredge_local/sensor.py | 4 ++-- requirements_all.txt | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/solaredge_local/manifest.json b/homeassistant/components/solaredge_local/manifest.json index 960ff07b7503..d65aa06ea0ab 100644 --- a/homeassistant/components/solaredge_local/manifest.json +++ b/homeassistant/components/solaredge_local/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/solaredge_local", "iot_class": "local_polling", "loggers": ["solaredge_local"], - "requirements": ["solaredge-local==0.2.0"] + "requirements": ["solaredge-local==0.2.3"] } diff --git a/homeassistant/components/solaredge_local/sensor.py b/homeassistant/components/solaredge_local/sensor.py index 35e5d758433f..d0efcd0ec9b7 100644 --- a/homeassistant/components/solaredge_local/sensor.py +++ b/homeassistant/components/solaredge_local/sensor.py @@ -290,7 +290,7 @@ class SolarEdgeSensor(SensorEntity): """Return the state attributes.""" if extra_attr := self.entity_description.extra_attribute: try: - return {extra_attr: self._data.info[self.entity_description.key]} + return {extra_attr: self._data.info.get(self.entity_description.key)} except KeyError: pass return None @@ -298,7 +298,7 @@ class SolarEdgeSensor(SensorEntity): def update(self) -> None: """Get the latest data from the sensor and update the state.""" self._data.update() - self._attr_native_value = self._data.data[self.entity_description.key] + self._attr_native_value = self._data.data.get(self.entity_description.key) class SolarEdgeData: diff --git a/requirements_all.txt b/requirements_all.txt index 0945f790b2db..9b6eeeb2e546 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2369,7 +2369,7 @@ snapcast==2.3.2 soco==0.29.1 # homeassistant.components.solaredge_local -solaredge-local==0.2.0 +solaredge-local==0.2.3 # homeassistant.components.solaredge solaredge==0.0.2 From f8942e7dcc906c93bab7e63aff20ab3c6e19a67b Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 29 Apr 2023 13:10:52 +0200 Subject: [PATCH 066/822] Fix incorrect entity category for isy994 sensors (#92233) --- homeassistant/components/isy994/sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/isy994/sensor.py b/homeassistant/components/isy994/sensor.py index efad6dbc5cf9..3bd870767136 100644 --- a/homeassistant/components/isy994/sensor.py +++ b/homeassistant/components/isy994/sensor.py @@ -100,8 +100,8 @@ ISY_CONTROL_TO_STATE_CLASS = { control: SensorStateClass.MEASUREMENT for control in ISY_CONTROL_TO_DEVICE_CLASS } ISY_CONTROL_TO_ENTITY_CATEGORY = { - PROP_RAMP_RATE: EntityCategory.CONFIG, - PROP_ON_LEVEL: EntityCategory.CONFIG, + PROP_RAMP_RATE: EntityCategory.DIAGNOSTIC, + PROP_ON_LEVEL: EntityCategory.DIAGNOSTIC, PROP_COMMS_ERROR: EntityCategory.DIAGNOSTIC, } From 06c4da2bb69fe5822438dede2a4d14b1cc5b8fec Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 29 Apr 2023 13:11:50 +0200 Subject: [PATCH 067/822] Fix incorrect entity category for flux_led sensors (#92234) --- homeassistant/components/flux_led/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/flux_led/sensor.py b/homeassistant/components/flux_led/sensor.py index 664d18f385b2..3cff6d017f02 100644 --- a/homeassistant/components/flux_led/sensor.py +++ b/homeassistant/components/flux_led/sensor.py @@ -36,7 +36,7 @@ class FluxPairedRemotes(FluxEntity, SensorEntity): """Representation of a Magic Home paired remotes sensor.""" _attr_icon = "mdi:remote" - _attr_entity_category = EntityCategory.CONFIG + _attr_entity_category = EntityCategory.DIAGNOSTIC @property def native_value(self) -> int: From acc4b001cd3cb2fa5eb6d14fd537f15e0fe625ab Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 29 Apr 2023 14:02:34 +0200 Subject: [PATCH 068/822] Fix history YAML deprecation (#92238) --- homeassistant/components/history/__init__.py | 21 ++++++++++---------- tests/components/history/test_init.py | 15 ++++++++++++-- 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/history/__init__.py b/homeassistant/components/history/__init__.py index 71200b93f3f1..f5b97a7fb13d 100644 --- a/homeassistant/components/history/__init__.py +++ b/homeassistant/components/history/__init__.py @@ -12,6 +12,7 @@ from homeassistant.components import frontend from homeassistant.components.http import HomeAssistantView from homeassistant.components.recorder import get_instance, history from homeassistant.components.recorder.util import session_scope +from homeassistant.const import CONF_EXCLUDE, CONF_INCLUDE from homeassistant.core import HomeAssistant, valid_entity_id import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entityfilter import INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA @@ -27,16 +28,16 @@ CONF_ORDER = "use_include_order" _ONE_DAY = timedelta(days=1) CONFIG_SCHEMA = vol.Schema( - vol.All( - cv.deprecated(DOMAIN), - { - DOMAIN: vol.All( - INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA.extend( - {vol.Optional(CONF_ORDER, default=False): cv.boolean} - ), - ) - }, - ), + { + DOMAIN: vol.All( + cv.deprecated(CONF_INCLUDE), + cv.deprecated(CONF_EXCLUDE), + cv.deprecated(CONF_ORDER), + INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA.extend( + {vol.Optional(CONF_ORDER, default=False): cv.boolean} + ), + ) + }, extra=vol.ALLOW_EXTRA, ) diff --git a/tests/components/history/test_init.py b/tests/components/history/test_init.py index 46b5773bfaa1..30c84c56f001 100644 --- a/tests/components/history/test_init.py +++ b/tests/components/history/test_init.py @@ -406,7 +406,10 @@ async def test_fetch_period_api( async def test_fetch_period_api_with_use_include_order( - recorder_mock: Recorder, hass: HomeAssistant, hass_client: ClientSessionGenerator + recorder_mock: Recorder, + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, ) -> None: """Test the fetch period view for history with include order.""" await async_setup_component( @@ -418,6 +421,8 @@ async def test_fetch_period_api_with_use_include_order( ) assert response.status == HTTPStatus.OK + assert "The 'use_include_order' option is deprecated" in caplog.text + async def test_fetch_period_api_with_minimal_response( recorder_mock: Recorder, hass: HomeAssistant, hass_client: ClientSessionGenerator @@ -472,7 +477,10 @@ async def test_fetch_period_api_with_no_timestamp( async def test_fetch_period_api_with_include_order( - recorder_mock: Recorder, hass: HomeAssistant, hass_client: ClientSessionGenerator + recorder_mock: Recorder, + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, ) -> None: """Test the fetch period view for history.""" await async_setup_component( @@ -492,6 +500,9 @@ async def test_fetch_period_api_with_include_order( ) assert response.status == HTTPStatus.OK + assert "The 'use_include_order' option is deprecated" in caplog.text + assert "The 'include' option is deprecated" in caplog.text + async def test_entity_ids_limit_via_api( recorder_mock: Recorder, hass: HomeAssistant, hass_client: ClientSessionGenerator From 1028841690323f9bca322bdd5dcd320fa77bcbd5 Mon Sep 17 00:00:00 2001 From: rikroe <42204099+rikroe@users.noreply.github.com> Date: Sat, 29 Apr 2023 17:41:34 +0200 Subject: [PATCH 069/822] Move BMW Target SoC to number platform (#91081) Co-authored-by: Franck Nijhof Co-authored-by: rikroe --- .../bmw_connected_drive/__init__.py | 1 + .../bmw_connected_drive/manifest.json | 2 +- .../components/bmw_connected_drive/number.py | 120 +++++++++++++++++ .../components/bmw_connected_drive/select.py | 15 +-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../snapshots/test_diagnostics.ambr | 76 +++++++++++ .../snapshots/test_number.ambr | 22 ++++ .../snapshots/test_select.ambr | 32 ----- .../bmw_connected_drive/test_number.py | 123 ++++++++++++++++++ .../bmw_connected_drive/test_select.py | 2 - 11 files changed, 346 insertions(+), 51 deletions(-) create mode 100644 homeassistant/components/bmw_connected_drive/number.py create mode 100644 tests/components/bmw_connected_drive/snapshots/test_number.ambr create mode 100644 tests/components/bmw_connected_drive/test_number.py diff --git a/homeassistant/components/bmw_connected_drive/__init__.py b/homeassistant/components/bmw_connected_drive/__init__.py index e91943034dfc..8d5d842e915c 100644 --- a/homeassistant/components/bmw_connected_drive/__init__.py +++ b/homeassistant/components/bmw_connected_drive/__init__.py @@ -41,6 +41,7 @@ PLATFORMS = [ Platform.DEVICE_TRACKER, Platform.LOCK, Platform.NOTIFY, + Platform.NUMBER, Platform.SELECT, Platform.SENSOR, ] diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index f1768d5a0c7a..3c7d2ba27c3a 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", "iot_class": "cloud_polling", "loggers": ["bimmer_connected"], - "requirements": ["bimmer_connected==0.13.0"] + "requirements": ["bimmer_connected==0.13.2"] } diff --git a/homeassistant/components/bmw_connected_drive/number.py b/homeassistant/components/bmw_connected_drive/number.py new file mode 100644 index 000000000000..f26a2027f72c --- /dev/null +++ b/homeassistant/components/bmw_connected_drive/number.py @@ -0,0 +1,120 @@ +"""Number platform for BMW.""" + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +import logging +from typing import Any + +from bimmer_connected.models import MyBMWAPIError +from bimmer_connected.vehicle import MyBMWVehicle + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, + NumberMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import BMWBaseEntity +from .const import DOMAIN +from .coordinator import BMWDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class BMWRequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[MyBMWVehicle], float | int | None] + remote_service: Callable[[MyBMWVehicle, float | int], Coroutine[Any, Any, Any]] + + +@dataclass +class BMWNumberEntityDescription(NumberEntityDescription, BMWRequiredKeysMixin): + """Describes BMW number entity.""" + + is_available: Callable[[MyBMWVehicle], bool] = lambda _: False + dynamic_options: Callable[[MyBMWVehicle], list[str]] | None = None + mode: NumberMode = NumberMode.AUTO + + +NUMBER_TYPES: list[BMWNumberEntityDescription] = [ + BMWNumberEntityDescription( + key="target_soc", + name="Target SoC", + device_class=NumberDeviceClass.BATTERY, + is_available=lambda v: v.is_remote_set_target_soc_enabled, + native_max_value=100.0, + native_min_value=20.0, + native_step=5.0, + mode=NumberMode.SLIDER, + value_fn=lambda v: v.fuel_and_battery.charging_target, + remote_service=lambda v, o: v.remote_services.trigger_charging_settings_update( + target_soc=int(o) + ), + icon="mdi:battery-charging-medium", + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the MyBMW number from config entry.""" + coordinator: BMWDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + entities: list[BMWNumber] = [] + + for vehicle in coordinator.account.vehicles: + if not coordinator.read_only: + entities.extend( + [ + BMWNumber(coordinator, vehicle, description) + for description in NUMBER_TYPES + if description.is_available(vehicle) + ] + ) + async_add_entities(entities) + + +class BMWNumber(BMWBaseEntity, NumberEntity): + """Representation of BMW Number entity.""" + + entity_description: BMWNumberEntityDescription + + def __init__( + self, + coordinator: BMWDataUpdateCoordinator, + vehicle: MyBMWVehicle, + description: BMWNumberEntityDescription, + ) -> None: + """Initialize an BMW Number.""" + super().__init__(coordinator, vehicle) + self.entity_description = description + self._attr_unique_id = f"{vehicle.vin}-{description.key}" + self._attr_mode = description.mode + + @property + def native_value(self) -> float | None: + """Return the entity value to represent the entity state.""" + return self.entity_description.value_fn(self.vehicle) + + async def async_set_native_value(self, value: float) -> None: + """Update to the vehicle.""" + _LOGGER.debug( + "Executing '%s' on vehicle '%s' to value '%s'", + self.entity_description.key, + self.vehicle.vin, + value, + ) + try: + await self.entity_description.remote_service(self.vehicle, value) + except MyBMWAPIError as ex: + raise HomeAssistantError(ex) from ex diff --git a/homeassistant/components/bmw_connected_drive/select.py b/homeassistant/components/bmw_connected_drive/select.py index e8e8dd5ca407..52d35b477a2d 100644 --- a/homeassistant/components/bmw_connected_drive/select.py +++ b/homeassistant/components/bmw_connected_drive/select.py @@ -9,7 +9,7 @@ from bimmer_connected.vehicle.charging_profile import ChargingMode from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry -from homeassistant.const import PERCENTAGE, UnitOfElectricCurrent +from homeassistant.const import UnitOfElectricCurrent from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -37,19 +37,6 @@ class BMWSelectEntityDescription(SelectEntityDescription, BMWRequiredKeysMixin): SELECT_TYPES: dict[str, BMWSelectEntityDescription] = { - # --- Generic --- - "target_soc": BMWSelectEntityDescription( - key="target_soc", - name="Target SoC", - is_available=lambda v: v.is_remote_set_target_soc_enabled, - options=[str(i * 5 + 20) for i in range(17)], - current_option=lambda v: str(v.fuel_and_battery.charging_target), - remote_service=lambda v, o: v.remote_services.trigger_charging_settings_update( - target_soc=int(o) - ), - icon="mdi:battery-charging-medium", - unit_of_measurement=PERCENTAGE, - ), "ac_limit": BMWSelectEntityDescription( key="ac_limit", name="AC Charging Limit", diff --git a/requirements_all.txt b/requirements_all.txt index 9b6eeeb2e546..66640e25bca1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -431,7 +431,7 @@ beautifulsoup4==4.11.1 bellows==0.35.2 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.13.0 +bimmer_connected==0.13.2 # homeassistant.components.bizkaibus bizkaibus==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6ecb2e3b8bc3..cd0fea6b33f3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -364,7 +364,7 @@ beautifulsoup4==4.11.1 bellows==0.35.2 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.13.0 +bimmer_connected==0.13.2 # homeassistant.components.bluetooth bleak-retry-connector==3.0.2 diff --git a/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr b/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr index 2cd6622d14e8..7ee3f6259114 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr @@ -139,6 +139,22 @@ }), ]), }), + 'climate': dict({ + 'account_timezone': dict({ + '_dst_offset': '0:00:00', + '_dst_saved': '0:00:00', + '_hasdst': False, + '_std_offset': '0:00:00', + '_tznames': list([ + 'UTC', + 'UTC', + ]), + }), + 'activity': 'STANDBY', + 'activity_end_time': None, + 'activity_end_time_no_tz': None, + 'is_climate_on': False, + }), 'condition_based_services': dict({ 'is_service_required': False, 'messages': list([ @@ -808,6 +824,32 @@ ]), 'name': 'i4 eDrive40', 'timestamp': '2023-01-04T14:57:06+00:00', + 'tires': dict({ + 'front_left': dict({ + 'current_pressure': 241, + 'manufacturing_week': '2021-10-04T00:00:00', + 'season': 2, + 'target_pressure': 269, + }), + 'front_right': dict({ + 'current_pressure': 255, + 'manufacturing_week': '2019-06-10T00:00:00', + 'season': 2, + 'target_pressure': 269, + }), + 'rear_left': dict({ + 'current_pressure': 324, + 'manufacturing_week': '2019-03-18T00:00:00', + 'season': 2, + 'target_pressure': 303, + }), + 'rear_right': dict({ + 'current_pressure': 331, + 'manufacturing_week': '2019-03-18T00:00:00', + 'season': 2, + 'target_pressure': 303, + }), + }), 'vehicle_location': dict({ 'account_region': 'row', 'heading': '**REDACTED**', @@ -969,6 +1011,22 @@ 'messages': list([ ]), }), + 'climate': dict({ + 'account_timezone': dict({ + '_dst_offset': '0:00:00', + '_dst_saved': '0:00:00', + '_hasdst': False, + '_std_offset': '0:00:00', + '_tznames': list([ + 'UTC', + 'UTC', + ]), + }), + 'activity': 'UNKNOWN', + 'activity_end_time': None, + 'activity_end_time_no_tz': None, + 'is_climate_on': False, + }), 'condition_based_services': dict({ 'is_service_required': False, 'messages': list([ @@ -1466,6 +1524,7 @@ ]), 'name': 'i3 (+ REX)', 'timestamp': '2022-07-10T09:25:53+00:00', + 'tires': None, 'vehicle_location': dict({ 'account_region': 'row', 'heading': None, @@ -2456,6 +2515,22 @@ 'messages': list([ ]), }), + 'climate': dict({ + 'account_timezone': dict({ + '_dst_offset': '0:00:00', + '_dst_saved': '0:00:00', + '_hasdst': False, + '_std_offset': '0:00:00', + '_tznames': list([ + 'UTC', + 'UTC', + ]), + }), + 'activity': 'UNKNOWN', + 'activity_end_time': None, + 'activity_end_time_no_tz': None, + 'is_climate_on': False, + }), 'condition_based_services': dict({ 'is_service_required': False, 'messages': list([ @@ -2953,6 +3028,7 @@ ]), 'name': 'i3 (+ REX)', 'timestamp': '2022-07-10T09:25:53+00:00', + 'tires': None, 'vehicle_location': dict({ 'account_region': 'row', 'heading': None, diff --git a/tests/components/bmw_connected_drive/snapshots/test_number.ambr b/tests/components/bmw_connected_drive/snapshots/test_number.ambr new file mode 100644 index 000000000000..a99d8bb3e0ff --- /dev/null +++ b/tests/components/bmw_connected_drive/snapshots/test_number.ambr @@ -0,0 +1,22 @@ +# serializer version: 1 +# name: test_entity_state_attrs + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'battery', + 'friendly_name': 'i4 eDrive40 Target SoC', + 'icon': 'mdi:battery-charging-medium', + 'max': 100.0, + 'min': 20.0, + 'mode': , + 'step': 5.0, + }), + 'context': , + 'entity_id': 'number.i4_edrive40_target_soc', + 'last_changed': , + 'last_updated': , + 'state': '80', + }), + ]) +# --- diff --git a/tests/components/bmw_connected_drive/snapshots/test_select.ambr b/tests/components/bmw_connected_drive/snapshots/test_select.ambr index e6902fbacfd5..522e74c61e29 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_select.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_select.ambr @@ -1,38 +1,6 @@ # serializer version: 1 # name: test_entity_state_attrs list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'i4 eDrive40 Target SoC', - 'icon': 'mdi:battery-charging-medium', - 'options': list([ - '20', - '25', - '30', - '35', - '40', - '45', - '50', - '55', - '60', - '65', - '70', - '75', - '80', - '85', - '90', - '95', - '100', - ]), - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'select.i4_edrive40_target_soc', - 'last_changed': , - 'last_updated': , - 'state': '80', - }), StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by MyBMW', diff --git a/tests/components/bmw_connected_drive/test_number.py b/tests/components/bmw_connected_drive/test_number.py new file mode 100644 index 000000000000..b6c16af3e03f --- /dev/null +++ b/tests/components/bmw_connected_drive/test_number.py @@ -0,0 +1,123 @@ +"""Test BMW numbers.""" +from unittest.mock import AsyncMock + +from bimmer_connected.models import MyBMWAPIError, MyBMWRemoteServiceError +from bimmer_connected.vehicle.remote_services import RemoteServices +import pytest +import respx +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from . import setup_mocked_integration + + +async def test_entity_state_attrs( + hass: HomeAssistant, + bmw_fixture: respx.Router, + snapshot: SnapshotAssertion, +) -> None: + """Test number options and values..""" + + # Setup component + assert await setup_mocked_integration(hass) + + # Get all number entities + assert hass.states.async_all("number") == snapshot + + +@pytest.mark.parametrize( + ("entity_id", "value"), + [ + ("number.i4_edrive40_target_soc", "80"), + ], +) +async def test_update_triggers_success( + hass: HomeAssistant, + entity_id: str, + value: str, + bmw_fixture: respx.Router, +) -> None: + """Test allowed values for number inputs.""" + + # Setup component + assert await setup_mocked_integration(hass) + + # Test + await hass.services.async_call( + "number", + "set_value", + service_data={"value": value}, + blocking=True, + target={"entity_id": entity_id}, + ) + assert RemoteServices.trigger_remote_service.call_count == 1 + + +@pytest.mark.parametrize( + ("entity_id", "value"), + [ + ("number.i4_edrive40_target_soc", "81"), + ], +) +async def test_update_triggers_fail( + hass: HomeAssistant, + entity_id: str, + value: str, + bmw_fixture: respx.Router, +) -> None: + """Test not allowed values for number inputs.""" + + # Setup component + assert await setup_mocked_integration(hass) + + # Test + with pytest.raises(ValueError): + await hass.services.async_call( + "number", + "set_value", + service_data={"value": value}, + blocking=True, + target={"entity_id": entity_id}, + ) + assert RemoteServices.trigger_remote_service.call_count == 0 + + +@pytest.mark.parametrize( + ("raised", "expected"), + [ + (MyBMWRemoteServiceError, HomeAssistantError), + (MyBMWAPIError, HomeAssistantError), + (ValueError, ValueError), + ], +) +async def test_update_triggers_exceptions( + hass: HomeAssistant, + raised: Exception, + expected: Exception, + bmw_fixture: respx.Router, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test not allowed values for number inputs.""" + + # Setup component + assert await setup_mocked_integration(hass) + + # Setup exception + monkeypatch.setattr( + RemoteServices, + "trigger_remote_service", + AsyncMock(side_effect=raised), + ) + + # Test + with pytest.raises(expected): + await hass.services.async_call( + "number", + "set_value", + service_data={"value": "80"}, + blocking=True, + target={"entity_id": "number.i4_edrive40_target_soc"}, + ) + assert RemoteServices.trigger_remote_service.call_count == 1 diff --git a/tests/components/bmw_connected_drive/test_select.py b/tests/components/bmw_connected_drive/test_select.py index 92daf157a70c..bbef62b14eda 100644 --- a/tests/components/bmw_connected_drive/test_select.py +++ b/tests/components/bmw_connected_drive/test_select.py @@ -28,7 +28,6 @@ async def test_entity_state_attrs( [ ("select.i3_rex_charging_mode", "IMMEDIATE_CHARGING"), ("select.i4_edrive40_ac_charging_limit", "16"), - ("select.i4_edrive40_target_soc", "80"), ("select.i4_edrive40_charging_mode", "DELAYED_CHARGING"), ], ) @@ -58,7 +57,6 @@ async def test_update_triggers_success( ("entity_id", "value"), [ ("select.i4_edrive40_ac_charging_limit", "17"), - ("select.i4_edrive40_target_soc", "81"), ], ) async def test_update_triggers_fail( From 37723792c7bf6a82cb06315c792672d128b34bea Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 29 Apr 2023 17:47:04 +0200 Subject: [PATCH 070/822] Fix unknown/unavailable source sensor in Filter entities (#92241) --- homeassistant/components/filter/sensor.py | 11 +++++++++-- tests/components/filter/test_sensor.py | 6 ++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/filter/sensor.py b/homeassistant/components/filter/sensor.py index 7b2321e172e4..9b1e2250a28c 100644 --- a/homeassistant/components/filter/sensor.py +++ b/homeassistant/components/filter/sensor.py @@ -236,11 +236,18 @@ class SensorFilter(SensorEntity): self.async_write_ha_state() return - if new_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE): - self._state = new_state.state + if new_state.state == STATE_UNKNOWN: + self._state = None self.async_write_ha_state() return + if new_state.state == STATE_UNAVAILABLE: + self._attr_available = False + self.async_write_ha_state() + return + + self._attr_available = True + temp_state = _State(new_state.last_updated, new_state.state) try: diff --git a/tests/components/filter/test_sensor.py b/tests/components/filter/test_sensor.py index 5ac03aea13dd..26df432a2705 100644 --- a/tests/components/filter/test_sensor.py +++ b/tests/components/filter/test_sensor.py @@ -308,6 +308,12 @@ async def test_invalid_state(recorder_mock: Recorder, hass: HomeAssistant) -> No assert await async_setup_component(hass, "sensor", config) await hass.async_block_till_done() + hass.states.async_set("sensor.test_monitored", "unknown") + await hass.async_block_till_done() + + state = hass.states.get("sensor.test") + assert state.state == STATE_UNKNOWN + hass.states.async_set("sensor.test_monitored", STATE_UNAVAILABLE) await hass.async_block_till_done() From a48a07bd8de3d21890ab1c14b7a3024bea0d2bbb Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Sat, 29 Apr 2023 17:27:39 +0100 Subject: [PATCH 071/822] Removing self from SQL sensor CODEOWNERS (#92250) --- CODEOWNERS | 4 ++-- homeassistant/components/sql/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index e426d5f98b56..6d9de8f19e34 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1144,8 +1144,8 @@ build.json @home-assistant/supervisor /homeassistant/components/splunk/ @Bre77 /homeassistant/components/spotify/ @frenck /tests/components/spotify/ @frenck -/homeassistant/components/sql/ @dgomes @gjohansson-ST @dougiteixeira -/tests/components/sql/ @dgomes @gjohansson-ST @dougiteixeira +/homeassistant/components/sql/ @gjohansson-ST @dougiteixeira +/tests/components/sql/ @gjohansson-ST @dougiteixeira /homeassistant/components/squeezebox/ @rajlaud /tests/components/squeezebox/ @rajlaud /homeassistant/components/srp_energy/ @briglx diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index af8394e2ad7d..0dfbc0c53935 100644 --- a/homeassistant/components/sql/manifest.json +++ b/homeassistant/components/sql/manifest.json @@ -1,7 +1,7 @@ { "domain": "sql", "name": "SQL", - "codeowners": ["@dgomes", "@gjohansson-ST", "@dougiteixeira"], + "codeowners": ["@gjohansson-ST", "@dougiteixeira"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sql", "iot_class": "local_polling", From 0e0ab4427b37b656d3a52fc0c428a23330b9dcc2 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Sat, 29 Apr 2023 18:34:21 +0200 Subject: [PATCH 072/822] Revert "Add silent option for DynamicShutter (ogp:Shutter) in Overkiz" (#91354) --- homeassistant/components/overkiz/switch.py | 27 ++++------------------ 1 file changed, 4 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/overkiz/switch.py b/homeassistant/components/overkiz/switch.py index a40bd731a0f2..b7416711e77e 100644 --- a/homeassistant/components/overkiz/switch.py +++ b/homeassistant/components/overkiz/switch.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from typing import Any, cast +from typing import Any from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState from pyoverkiz.enums.ui import UIClass, UIWidget @@ -15,12 +15,12 @@ from homeassistant.components.switch import ( SwitchEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory +from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import HomeAssistantOverkizData -from .const import DOMAIN, IGNORED_OVERKIZ_DEVICES +from .const import DOMAIN from .entity import OverkizDescriptiveEntity @@ -107,19 +107,6 @@ SWITCH_DESCRIPTIONS: list[OverkizSwitchDescription] = [ ), entity_category=EntityCategory.CONFIG, ), - OverkizSwitchDescription( - key=UIWidget.DYNAMIC_SHUTTER, - name="Silent mode", - turn_on=OverkizCommand.ACTIVATE_OPTION, - turn_on_args=OverkizCommandParam.SILENCE, - turn_off=OverkizCommand.DEACTIVATE_OPTION, - turn_off_args=OverkizCommandParam.SILENCE, - is_on=lambda select_state: ( - OverkizCommandParam.SILENCE - in cast(list, select_state(OverkizState.CORE_ACTIVATED_OPTIONS)) - ), - icon="mdi:feather", - ), ] SUPPORTED_DEVICES = { @@ -136,13 +123,7 @@ async def async_setup_entry( data: HomeAssistantOverkizData = hass.data[DOMAIN][entry.entry_id] entities: list[OverkizSwitch] = [] - for device in data.coordinator.data.values(): - if ( - device.widget in IGNORED_OVERKIZ_DEVICES - or device.ui_class in IGNORED_OVERKIZ_DEVICES - ): - continue - + for device in data.platforms[Platform.SWITCH]: if description := SUPPORTED_DEVICES.get(device.widget) or SUPPORTED_DEVICES.get( device.ui_class ): From e136070718dcd4be643734622c5d829adafd513e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 29 Apr 2023 11:45:14 -0500 Subject: [PATCH 073/822] Speed up purging the database (#92247) --- homeassistant/components/recorder/purge.py | 38 +++++++++++----------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/recorder/purge.py b/homeassistant/components/recorder/purge.py index 95013de125dc..9dff59d1f593 100644 --- a/homeassistant/components/recorder/purge.py +++ b/homeassistant/components/recorder/purge.py @@ -245,12 +245,12 @@ def _select_state_attributes_ids_to_purge( """Return sets of state and attribute ids to purge.""" state_ids = set() attributes_ids = set() - for state in session.execute( + for state_id, attributes_id in session.execute( find_states_to_purge(dt_util.utc_to_timestamp(purge_before)) ).all(): - state_ids.add(state.state_id) - if state.attributes_id: - attributes_ids.add(state.attributes_id) + state_ids.add(state_id) + if attributes_id: + attributes_ids.add(attributes_id) _LOGGER.debug( "Selected %s state ids and %s attributes_ids to remove", len(state_ids), @@ -265,12 +265,12 @@ def _select_event_data_ids_to_purge( """Return sets of event and data ids to purge.""" event_ids = set() data_ids = set() - for event in session.execute( + for event_id, data_id in session.execute( find_events_to_purge(dt_util.utc_to_timestamp(purge_before)) ).all(): - event_ids.add(event.event_id) - if event.data_id: - data_ids.add(event.data_id) + event_ids.add(event_id) + if data_id: + data_ids.add(data_id) _LOGGER.debug( "Selected %s event ids and %s data_ids to remove", len(event_ids), len(data_ids) ) @@ -412,7 +412,7 @@ def _select_statistics_runs_to_purge( Takes care to keep the newest run. """ statistic_runs = session.execute(find_statistics_runs_to_purge(purge_before)).all() - statistic_runs_list = [run.run_id for run in statistic_runs] + statistic_runs_list = [run_id for (run_id,) in statistic_runs] # Exclude the newest statistics run if ( last_run := session.execute(find_latest_statistics_runs_run_id()).scalar() @@ -431,7 +431,7 @@ def _select_short_term_statistics_to_purge( find_short_term_statistics_to_purge(purge_before) ).all() _LOGGER.debug("Selected %s short term statistics to remove", len(statistics)) - return [statistic.id for statistic in statistics] + return [statistic_id for (statistic_id,) in statistics] def _select_legacy_detached_state_and_attributes_and_data_ids_to_purge( @@ -451,10 +451,10 @@ def _select_legacy_detached_state_and_attributes_and_data_ids_to_purge( _LOGGER.debug("Selected %s state ids to remove", len(states)) state_ids = set() attributes_ids = set() - for state in states: - if state_id := state.state_id: + for state_id, attributes_id in states: + if state_id: state_ids.add(state_id) - if attributes_id := state.attributes_id: + if attributes_id: attributes_ids.add(attributes_id) return state_ids, attributes_ids @@ -478,13 +478,13 @@ def _select_legacy_event_state_and_attributes_and_data_ids_to_purge( state_ids = set() attributes_ids = set() data_ids = set() - for event in events: - event_ids.add(event.event_id) - if state_id := event.state_id: + for event_id, data_id, state_id, attributes_id in events: + event_ids.add(event_id) + if state_id: state_ids.add(state_id) - if attributes_id := event.attributes_id: + if attributes_id: attributes_ids.add(attributes_id) - if data_id := event.data_id: + if data_id: data_ids.add(data_id) return event_ids, state_ids, attributes_ids, data_ids @@ -726,7 +726,7 @@ def _purge_filtered_events( .filter(States.event_id.in_(event_ids_set)) .all() ) - and (state_ids := {state.state_id for state in states}) + and (state_ids := {state_id for (state_id,) in states}) ): # These are legacy states that are linked to an event that are no longer # created but since we did not remove them when we stopped adding new ones From e08d5d240d5c9efb167ec51802c94fffa4d70272 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Sat, 29 Apr 2023 18:49:15 +0200 Subject: [PATCH 074/822] Bump plugwise to v0.31.1 (#92249) --- 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 a8a1744c95e6..4fdcd0a8bdd6 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.0"], + "requirements": ["plugwise==0.31.1"], "zeroconf": ["_plugwise._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 66640e25bca1..c8cbac703905 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1368,7 +1368,7 @@ plexauth==0.0.6 plexwebsocket==0.0.13 # homeassistant.components.plugwise -plugwise==0.31.0 +plugwise==0.31.1 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cd0fea6b33f3..afa8a3716f0e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1016,7 +1016,7 @@ plexauth==0.0.6 plexwebsocket==0.0.13 # homeassistant.components.plugwise -plugwise==0.31.0 +plugwise==0.31.1 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 From d8bc37c695bd43362fad12d3f0fba1ff9f52a451 Mon Sep 17 00:00:00 2001 From: IceBotYT <34712694+IceBotYT@users.noreply.github.com> Date: Sat, 29 Apr 2023 12:50:22 -0400 Subject: [PATCH 075/822] Bump LaCrosse View to version 1.0.1 (#92213) --- homeassistant/components/lacrosse_view/manifest.json | 3 ++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lacrosse_view/manifest.json b/homeassistant/components/lacrosse_view/manifest.json index 51c15d4806e7..1236f63ddad8 100644 --- a/homeassistant/components/lacrosse_view/manifest.json +++ b/homeassistant/components/lacrosse_view/manifest.json @@ -5,5 +5,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/lacrosse_view", "iot_class": "cloud_polling", - "requirements": ["lacrosse-view==0.0.9"] + "loggers": ["lacrosse_view"], + "requirements": ["lacrosse-view==1.0.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index c8cbac703905..6664c978bff0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1028,7 +1028,7 @@ konnected==1.2.0 krakenex==2.1.0 # homeassistant.components.lacrosse_view -lacrosse-view==0.0.9 +lacrosse-view==1.0.1 # homeassistant.components.eufy lakeside==0.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index afa8a3716f0e..74a870b4bb93 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -784,7 +784,7 @@ konnected==1.2.0 krakenex==2.1.0 # homeassistant.components.lacrosse_view -lacrosse-view==0.0.9 +lacrosse-view==1.0.1 # homeassistant.components.laundrify laundrify_aio==1.1.2 From ac4d9216d6d4477657fbe1b07129ed6bd95b4fa8 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 29 Apr 2023 18:51:38 +0200 Subject: [PATCH 076/822] Turn AVM FRITZ!Box Tools call deflection switches into coordinator entities (#91913) Co-authored-by: Franck Nijhof --- .../components/fritz/binary_sensor.py | 5 +- homeassistant/components/fritz/common.py | 61 +++++-- homeassistant/components/fritz/sensor.py | 2 +- homeassistant/components/fritz/switch.py | 164 ++++++++++-------- 4 files changed, 140 insertions(+), 92 deletions(-) diff --git a/homeassistant/components/fritz/binary_sensor.py b/homeassistant/components/fritz/binary_sensor.py index d2edb99e0265..6d371a82c95a 100644 --- a/homeassistant/components/fritz/binary_sensor.py +++ b/homeassistant/components/fritz/binary_sensor.py @@ -80,7 +80,10 @@ class FritzBoxBinarySensor(FritzBoxBaseCoordinatorEntity, BinarySensorEntity): def is_on(self) -> bool | None: """Return true if the binary sensor is on.""" if isinstance( - state := self.coordinator.data.get(self.entity_description.key), bool + state := self.coordinator.data["entity_states"].get( + self.entity_description.key + ), + bool, ): return state return None diff --git a/homeassistant/components/fritz/common.py b/homeassistant/components/fritz/common.py index 89a51581bf70..fa35b240d985 100644 --- a/homeassistant/components/fritz/common.py +++ b/homeassistant/components/fritz/common.py @@ -19,6 +19,7 @@ from fritzconnection.core.exceptions import ( from fritzconnection.lib.fritzhosts import FritzHosts from fritzconnection.lib.fritzstatus import FritzStatus from fritzconnection.lib.fritzwlan import DEFAULT_PASSWORD_LENGTH, FritzGuestWLAN +import xmltodict from homeassistant.components.device_tracker import ( CONF_CONSIDER_HOME, @@ -137,8 +138,15 @@ class HostInfo(TypedDict): status: bool +class UpdateCoordinatorDataType(TypedDict): + """Update coordinator data type.""" + + call_deflections: dict[int, dict] + entity_states: dict[str, StateType | bool] + + class FritzBoxTools( - update_coordinator.DataUpdateCoordinator[dict[str, bool | StateType]] + update_coordinator.DataUpdateCoordinator[UpdateCoordinatorDataType] ): """FritzBoxTools class.""" @@ -173,6 +181,7 @@ class FritzBoxTools( self.password = password self.port = port self.username = username + self.has_call_deflections: bool = False self._model: str | None = None self._current_firmware: str | None = None self._latest_firmware: str | None = None @@ -243,6 +252,8 @@ class FritzBoxTools( ) self.device_is_router = self.fritz_status.has_wan_enabled + self.has_call_deflections = "X_AVM-DE_OnTel1" in self.connection.services + def register_entity_updates( self, key: str, update_fn: Callable[[FritzStatus, StateType], Any] ) -> Callable[[], None]: @@ -259,20 +270,30 @@ class FritzBoxTools( self._entity_update_functions[key] = update_fn return unregister_entity_updates - async def _async_update_data(self) -> dict[str, bool | StateType]: + async def _async_update_data(self) -> UpdateCoordinatorDataType: """Update FritzboxTools data.""" - enity_data: dict[str, bool | StateType] = {} + entity_data: UpdateCoordinatorDataType = { + "call_deflections": {}, + "entity_states": {}, + } try: await self.async_scan_devices() for key, update_fn in self._entity_update_functions.items(): _LOGGER.debug("update entity %s", key) - enity_data[key] = await self.hass.async_add_executor_job( + entity_data["entity_states"][ + key + ] = await self.hass.async_add_executor_job( update_fn, self.fritz_status, self.data.get(key) ) + if self.has_call_deflections: + entity_data[ + "call_deflections" + ] = await self.async_update_call_deflections() except FRITZ_EXCEPTIONS as ex: raise update_coordinator.UpdateFailed(ex) from ex - _LOGGER.debug("enity_data: %s", enity_data) - return enity_data + + _LOGGER.debug("enity_data: %s", entity_data) + return entity_data @property def unique_id(self) -> str: @@ -354,6 +375,22 @@ class FritzBoxTools( """Retrieve latest device information from the FRITZ!Box.""" return await self.hass.async_add_executor_job(self._update_device_info) + async def async_update_call_deflections( + self, + ) -> dict[int, dict[str, Any]]: + """Call GetDeflections action from X_AVM-DE_OnTel service.""" + raw_data = await self.hass.async_add_executor_job( + partial(self.connection.call_action, "X_AVM-DE_OnTel1", "GetDeflections") + ) + if not raw_data: + return {} + + items = xmltodict.parse(raw_data["NewDeflectionList"])["List"]["Item"] + if not isinstance(items, list): + items = [items] + + return {int(item["DeflectionId"]): item for item in items} + async def _async_get_wan_access(self, ip_address: str) -> bool | None: """Get WAN access rule for given IP address.""" try: @@ -772,18 +809,6 @@ class AvmWrapper(FritzBoxTools): "WLANConfiguration", str(index), "GetInfo" ) - async def async_get_ontel_num_deflections(self) -> dict[str, Any]: - """Call GetNumberOfDeflections action from X_AVM-DE_OnTel service.""" - - return await self._async_service_call( - "X_AVM-DE_OnTel", "1", "GetNumberOfDeflections" - ) - - async def async_get_ontel_deflections(self) -> dict[str, Any]: - """Call GetDeflections action from X_AVM-DE_OnTel service.""" - - return await self._async_service_call("X_AVM-DE_OnTel", "1", "GetDeflections") - async def async_set_wlan_configuration( self, index: int, turn_on: bool ) -> dict[str, Any]: diff --git a/homeassistant/components/fritz/sensor.py b/homeassistant/components/fritz/sensor.py index 2b156046098d..d6b78c1cfc09 100644 --- a/homeassistant/components/fritz/sensor.py +++ b/homeassistant/components/fritz/sensor.py @@ -309,4 +309,4 @@ class FritzBoxSensor(FritzBoxBaseCoordinatorEntity, SensorEntity): @property def native_value(self) -> StateType: """Return the value reported by the sensor.""" - return self.coordinator.data.get(self.entity_description.key) + return self.coordinator.data["entity_states"].get(self.entity_description.key) diff --git a/homeassistant/components/fritz/switch.py b/homeassistant/components/fritz/switch.py index a26a0b2313fc..c8a7952ae2b5 100644 --- a/homeassistant/components/fritz/switch.py +++ b/homeassistant/components/fritz/switch.py @@ -4,10 +4,8 @@ from __future__ import annotations import logging from typing import Any -import xmltodict - from homeassistant.components.network import async_get_source_ip -from homeassistant.components.switch import SwitchEntity +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback @@ -15,6 +13,7 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo, Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import slugify from .common import ( @@ -47,31 +46,15 @@ async def _async_deflection_entities_list( _LOGGER.debug("Setting up %s switches", SWITCH_TYPE_DEFLECTION) - deflections_response = await avm_wrapper.async_get_ontel_num_deflections() - if not deflections_response: + if ( + call_deflections := avm_wrapper.data.get("call_deflections") + ) is None or not isinstance(call_deflections, dict): _LOGGER.debug("The FRITZ!Box has no %s options", SWITCH_TYPE_DEFLECTION) return [] - _LOGGER.debug( - "Specific %s response: GetNumberOfDeflections=%s", - SWITCH_TYPE_DEFLECTION, - deflections_response, - ) - - if deflections_response["NewNumberOfDeflections"] == 0: - _LOGGER.debug("The FRITZ!Box has no %s options", SWITCH_TYPE_DEFLECTION) - return [] - - if not (deflection_list := await avm_wrapper.async_get_ontel_deflections()): - return [] - - items = xmltodict.parse(deflection_list["NewDeflectionList"])["List"]["Item"] - if not isinstance(items, list): - items = [items] - return [ - FritzBoxDeflectionSwitch(avm_wrapper, device_friendly_name, dict_of_deflection) - for dict_of_deflection in items + FritzBoxDeflectionSwitch(avm_wrapper, device_friendly_name, cd_id) + for cd_id in call_deflections ] @@ -273,6 +256,61 @@ async def async_setup_entry( ) +class FritzBoxBaseCoordinatorSwitch(CoordinatorEntity, SwitchEntity): + """Fritz switch coordinator base class.""" + + coordinator: AvmWrapper + entity_description: SwitchEntityDescription + _attr_has_entity_name = True + + def __init__( + self, + avm_wrapper: AvmWrapper, + device_name: str, + description: SwitchEntityDescription, + ) -> None: + """Init device info class.""" + super().__init__(avm_wrapper) + self.entity_description = description + self._device_name = device_name + self._attr_unique_id = f"{avm_wrapper.unique_id}-{description.key}" + + @property + def device_info(self) -> DeviceInfo: + """Return the device information.""" + return DeviceInfo( + configuration_url=f"http://{self.coordinator.host}", + connections={(CONNECTION_NETWORK_MAC, self.coordinator.mac)}, + identifiers={(DOMAIN, self.coordinator.unique_id)}, + manufacturer="AVM", + model=self.coordinator.model, + name=self._device_name, + sw_version=self.coordinator.current_firmware, + ) + + @property + def data(self) -> dict[str, Any]: + """Return entity data from coordinator data.""" + raise NotImplementedError() + + @property + def available(self) -> bool: + """Return availability based on data availability.""" + return super().available and bool(self.data) + + async def _async_handle_turn_on_off(self, turn_on: bool) -> None: + """Handle switch state change request.""" + raise NotImplementedError() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on switch.""" + await self._async_handle_turn_on_off(turn_on=True) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off switch.""" + await self._async_handle_turn_on_off(turn_on=False) + + class FritzBoxBaseSwitch(FritzBoxBaseEntity): """Fritz switch base class.""" @@ -417,69 +455,51 @@ class FritzBoxPortSwitch(FritzBoxBaseSwitch, SwitchEntity): return bool(resp is not None) -class FritzBoxDeflectionSwitch(FritzBoxBaseSwitch, SwitchEntity): +class FritzBoxDeflectionSwitch(FritzBoxBaseCoordinatorSwitch): """Defines a FRITZ!Box Tools PortForward switch.""" + _attr_entity_category = EntityCategory.CONFIG + def __init__( self, avm_wrapper: AvmWrapper, device_friendly_name: str, - dict_of_deflection: Any, + deflection_id: int, ) -> None: """Init Fritxbox Deflection class.""" - self._avm_wrapper = avm_wrapper - - self.dict_of_deflection = dict_of_deflection - self._attributes = {} - self.id = int(self.dict_of_deflection["DeflectionId"]) - self._attr_entity_category = EntityCategory.CONFIG - - switch_info = SwitchInfo( - description=f"Call deflection {self.id}", - friendly_name=device_friendly_name, + self.deflection_id = deflection_id + description = SwitchEntityDescription( + key=f"call_deflection_{self.deflection_id}", + name=f"Call deflection {self.deflection_id}", icon="mdi:phone-forward", - type=SWITCH_TYPE_DEFLECTION, - callback_update=self._async_fetch_update, - callback_switch=self._async_switch_on_off_executor, ) - super().__init__(self._avm_wrapper, device_friendly_name, switch_info) + super().__init__(avm_wrapper, device_friendly_name, description) - async def _async_fetch_update(self) -> None: - """Fetch updates.""" + @property + def data(self) -> dict[str, Any]: + """Return call deflection data.""" + return self.coordinator.data["call_deflections"].get(self.deflection_id, {}) - resp = await self._avm_wrapper.async_get_ontel_deflections() - if not resp: - self._is_available = False - return + @property + def extra_state_attributes(self) -> dict[str, str]: + """Return device attributes.""" + return { + "type": self.data["Type"], + "number": self.data["Number"], + "deflection_to_number": self.data["DeflectionToNumber"], + "mode": self.data["Mode"][1:], + "outgoing": self.data["Outgoing"], + "phonebook_id": self.data["PhonebookID"], + } - self.dict_of_deflection = xmltodict.parse(resp["NewDeflectionList"])["List"][ - "Item" - ] - if isinstance(self.dict_of_deflection, list): - self.dict_of_deflection = self.dict_of_deflection[self.id] + @property + def is_on(self) -> bool | None: + """Switch status.""" + return self.data.get("Enable") == "1" - _LOGGER.debug( - "Specific %s response: NewDeflectionList=%s", - SWITCH_TYPE_DEFLECTION, - self.dict_of_deflection, - ) - - self._attr_is_on = self.dict_of_deflection["Enable"] == "1" - self._is_available = True - - self._attributes["type"] = self.dict_of_deflection["Type"] - self._attributes["number"] = self.dict_of_deflection["Number"] - self._attributes["deflection_to_number"] = self.dict_of_deflection[ - "DeflectionToNumber" - ] - # Return mode sample: "eImmediately" - self._attributes["mode"] = self.dict_of_deflection["Mode"][1:] - self._attributes["outgoing"] = self.dict_of_deflection["Outgoing"] - self._attributes["phonebook_id"] = self.dict_of_deflection["PhonebookID"] - - async def _async_switch_on_off_executor(self, turn_on: bool) -> None: + async def _async_handle_turn_on_off(self, turn_on: bool) -> None: """Handle deflection switch.""" - await self._avm_wrapper.async_set_deflection_enable(self.id, turn_on) + await self.coordinator.async_set_deflection_enable(self.deflection_id, turn_on) class FritzBoxProfileSwitch(FritzDeviceBase, SwitchEntity): From d23bd2ad681cb14a556bfed9352d5db737a215a3 Mon Sep 17 00:00:00 2001 From: IceBotYT <34712694+IceBotYT@users.noreply.github.com> Date: Sat, 29 Apr 2023 12:53:40 -0400 Subject: [PATCH 077/822] Add diagnostics to LaCrosse View (#92212) --- .../components/lacrosse_view/diagnostics.py | 28 +++++++++++++++ .../snapshots/test_diagnostics.ambr | 30 ++++++++++++++++ .../lacrosse_view/test_diagnostics.py | 36 +++++++++++++++++++ 3 files changed, 94 insertions(+) create mode 100644 homeassistant/components/lacrosse_view/diagnostics.py create mode 100644 tests/components/lacrosse_view/snapshots/test_diagnostics.ambr create mode 100644 tests/components/lacrosse_view/test_diagnostics.py diff --git a/homeassistant/components/lacrosse_view/diagnostics.py b/homeassistant/components/lacrosse_view/diagnostics.py new file mode 100644 index 000000000000..754cc39d38ee --- /dev/null +++ b/homeassistant/components/lacrosse_view/diagnostics.py @@ -0,0 +1,28 @@ +"""Diagnostics support for LaCrosse View.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import LaCrosseUpdateCoordinator + +TO_REDACT = {CONF_PASSWORD, CONF_USERNAME} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator: LaCrosseUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ + "coordinator" + ] + + return { + "entry": async_redact_data(entry.as_dict(), TO_REDACT), + "coordinator_data": coordinator.data, + } diff --git a/tests/components/lacrosse_view/snapshots/test_diagnostics.ambr b/tests/components/lacrosse_view/snapshots/test_diagnostics.ambr new file mode 100644 index 000000000000..30094f97cd35 --- /dev/null +++ b/tests/components/lacrosse_view/snapshots/test_diagnostics.ambr @@ -0,0 +1,30 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'coordinator_data': list([ + dict({ + '__type': "", + 'repr': "Sensor(name='Test', device_id='1', type='Test', sensor_id='2', sensor_field_names=['Temperature'], location=Location(id='1', name='Test'), permissions={'read': True}, model='Test', data={'Temperature': {'values': [{'s': '2'}], 'unit': 'degrees_celsius'}})", + }), + ]), + 'entry': dict({ + 'data': dict({ + 'id': '1', + 'name': 'Test', + 'password': '**REDACTED**', + 'username': '**REDACTED**', + }), + 'disabled_by': None, + 'domain': 'lacrosse_view', + 'entry_id': 'lacrosse_view_test_entry_id', + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Mock Title', + 'unique_id': None, + 'version': 1, + }), + }) +# --- diff --git a/tests/components/lacrosse_view/test_diagnostics.py b/tests/components/lacrosse_view/test_diagnostics.py new file mode 100644 index 000000000000..29d6f7cacbed --- /dev/null +++ b/tests/components/lacrosse_view/test_diagnostics.py @@ -0,0 +1,36 @@ +"""Test diagnostics of LaCrosse View.""" +from unittest.mock import patch + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.lacrosse_view import DOMAIN +from homeassistant.core import HomeAssistant + +from . import MOCK_ENTRY_DATA, TEST_SENSOR + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test config entry diagnostics.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data=MOCK_ENTRY_DATA, entry_id="lacrosse_view_test_entry_id" + ) + config_entry.add_to_hass(hass) + + with patch("lacrosse_view.LaCrosse.login", return_value=True), patch( + "lacrosse_view.LaCrosse.get_sensors", return_value=[TEST_SENSOR] + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + == snapshot + ) From 11131070abf29fda5918e5bf4b0eca39c84195ca Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 29 Apr 2023 11:59:44 -0500 Subject: [PATCH 078/822] Bump pyunifiprotect to 4.8.3 (#92251) --- homeassistant/components/unifiprotect/manifest.json | 2 +- homeassistant/components/unifiprotect/services.py | 3 ++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/unifiprotect/utils.py | 1 - 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index b0741c444063..afa7f2b5d4b8 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.8.2", "unifi-discovery==1.1.7"], + "requirements": ["pyunifiprotect==4.8.3", "unifi-discovery==1.1.7"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/components/unifiprotect/services.py b/homeassistant/components/unifiprotect/services.py index 915c51b6c0a2..90a2d5167c5d 100644 --- a/homeassistant/components/unifiprotect/services.py +++ b/homeassistant/components/unifiprotect/services.py @@ -161,8 +161,9 @@ async def set_chime_paired_doorbells(hass: HomeAssistant, call: ServiceCall) -> camera = instance.bootstrap.get_device_from_mac(doorbell_mac) assert camera is not None doorbell_ids.add(camera.id) + data_before_changed = chime.dict_with_excludes() chime.camera_ids = sorted(doorbell_ids) - await chime.save_device() + await chime.save_device(data_before_changed) def async_setup_services(hass: HomeAssistant) -> None: diff --git a/requirements_all.txt b/requirements_all.txt index 6664c978bff0..e5617e9c8575 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2149,7 +2149,7 @@ pytrafikverket==0.2.3 pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.8.2 +pyunifiprotect==4.8.3 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 74a870b4bb93..a3223eedcb6a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1554,7 +1554,7 @@ pytrafikverket==0.2.3 pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.8.2 +pyunifiprotect==4.8.3 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 diff --git a/tests/components/unifiprotect/utils.py b/tests/components/unifiprotect/utils.py index 2d6ab9937a3a..2a0a0eb0655f 100644 --- a/tests/components/unifiprotect/utils.py +++ b/tests/components/unifiprotect/utils.py @@ -150,7 +150,6 @@ def add_device( if regenerate_ids: regenerate_device_ids(device) - device._initial_data = device.dict() devices = getattr(bootstrap, f"{device.model.value}s") devices[device.id] = device From b9f2b0ad8be5f17a167311fb2d0d99b532ead546 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 29 Apr 2023 19:06:14 +0200 Subject: [PATCH 079/822] Update pyfibaro to 0.7.1 (#92248) --- homeassistant/components/fibaro/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/fibaro/manifest.json b/homeassistant/components/fibaro/manifest.json index 866be3fba548..4b3721eed15c 100644 --- a/homeassistant/components/fibaro/manifest.json +++ b/homeassistant/components/fibaro/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["pyfibaro"], - "requirements": ["pyfibaro==0.7.0"] + "requirements": ["pyfibaro==0.7.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index e5617e9c8575..e1dcdca833e5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1617,7 +1617,7 @@ pyevilgenius==2.0.0 pyezviz==0.2.0.9 # homeassistant.components.fibaro -pyfibaro==0.7.0 +pyfibaro==0.7.1 # homeassistant.components.fido pyfido==2.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a3223eedcb6a..2805a7c73308 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1175,7 +1175,7 @@ pyevilgenius==2.0.0 pyezviz==0.2.0.9 # homeassistant.components.fibaro -pyfibaro==0.7.0 +pyfibaro==0.7.1 # homeassistant.components.fido pyfido==2.1.2 From c35aabe4975f986f884fead968ef93dc43c0ba03 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Sat, 29 Apr 2023 14:24:56 -0500 Subject: [PATCH 080/822] Add VoIP error tone (#92260) * Play error tone when pipeline error occurs * Play listening tone at the start of each cycle --- homeassistant/components/voip/error.pcm | Bin 0 -> 64000 bytes homeassistant/components/voip/voip.py | 45 ++++++++++++++++++++---- tests/components/voip/test_voip.py | 4 +++ 3 files changed, 42 insertions(+), 7 deletions(-) create mode 100644 homeassistant/components/voip/error.pcm diff --git a/homeassistant/components/voip/error.pcm b/homeassistant/components/voip/error.pcm new file mode 100644 index 0000000000000000000000000000000000000000..3d93cdb14db728632917ddb65c1ee0a591ace9e0 GIT binary patch literal 64000 zcmaI81^g7%{{MewX7^Kcmw*T;h>D4=*F@}2?DiVhzP8uSYj?-B6$@;!3mZfvrMux% zyEEtieC_+5-!6W?-~aC8vomwz-F40xSkFI}eVyH%-IUG9V)wA?7!_dEL4*^k-pS!@4qzt}JEzxtp2vwnZy!*5T^$Nm>T z-$!nmKiPi^-0|5_{&D}9f0Ul7{!4$Kf6#yJm-#x^$*uJ7`-lB&eztGLQy;gJJJKD+ zZ!5QimP6ew?q>H7ccy#L&2Swe@4j*`y4T#}?w{^h{=Ri>qyEvrXsu|0d&-^Ru6GUY z^yu8ESM;Y_;Oe3}u5a;O=r)S>ineF|Ywlm}0XLp`LulDJn&X~u$GG*}`tEW!%{9C4 z-M#K`*X%#>1%I}I+9zEXW(K-_ZW0G z`uF^3Kh{t2bNoslyLH@FZjh^^JqJ&#{HOj!Kfw=k@4KLj_cuQ# zYv~vH4_p%EqVaJ2r@z$i0F@2?V)vxG%(ZdD{ae{vz@F<`M?1K?{fGVtq`$8lgVmhv z|K;EE8@Tt-)PXK?z0lKfZf!RKy|3~^xWCBV?%sB@+$!*G;6C*CV-r`RpR>^6V8)F^ znqz#?FY(VoopZms6Qh1nJJ-_B$hP$t_$Pcn_nYe#{pfac6S2hw**~(YvpxJ_t{$3y zK|@!-!>!r<*_do+pxnUdY3>qtuYWk}nC*ZSzl3$p_3hlYu7i6C+4S)J{UKQ7HLg3h zG}c}1c5zeD%0MjkOxGTU$=&b1rOUAT+1U_i_&S^GA9km@QFzO`zN;UVwaZ>g`(|hR zcig7Ym)O$)|9iG7+a5h#;BWI2eMdK$`y>3%{v7v?JIlT2WB)+*NY)n5yV;GwPPTB{ zxEEZjXd&Lx7Oz?l>Kfgl(TmaO=#1!RH_+YY2m6ctsqWHfP`qV4I_ef(@AB?yzac#B z>IOvbMV;cK;vw;%=urP;T9-~v@Ag+ltKy-_PVsH-5WhIvKU-MrP<%EW94$&dO)iSA z^PT*We21aiSKW@$PSFWbU3^k}c{JUxh2`BGjf^M7hsIY%!(AWWGTSvx((BR>vSIEY z(e2SI?gW2m_ECCd+A>`$os;h8C%WqRp7`(aXVDBi=3>8(f63qD?v2)u_lwtyPm3n` z=5$2yw&DfpZ`t46<D_~HY_QDAv7d&3`u?M}b~a_%BOJ)56?S3JG=?{u7hKiVpO zH`*mSHX0wzN5Y@`TeGjy_tTfN?cFueuhH>Q9JPpw?r3+4Z_FhrqUz+MxGMT2 zTRYvb*rjlE^YG%P@sBmVTbxn7S#(%KUNpi*xgmIQLHC;-UWRv_tl~TORM3 zoEU!`t%yd)lan_2-K(~(?q9uMe(kuJ?U}xlKI3nUdc)&8^S*Z9Hs&8R0a{T_dK@tMZ88vfR_PPR|- zX;q(^sAg%^kNJ`L=Bh%?S#=GyJ*sbvduB5WM;5ot3chFbOmszblPhL7r!$M|r5~i7 zv!2<*>0`xTiyOPs@@;Eg$baY3ba^%|+CBF~{-^w7$@Fyl`dyY^yrO&KJ;nWsyEX09 zIIQrC9~j>rMQ(NWS$b`u*tn=+cvF%-9c`FzU$r1NCD|Z1A-5>G%PlDE*?4}#;HH&@ z7qbQK;P{RB#dz!FgxqQQ$MT!!espgXyEhMSu1OzsXTkB-`KNPzlHt+5?qa<2y!gI& zSaeQyWZ~`R&9m2%^Q&IUoe}NnHjfUEMNo56F1`^zmpdiukoGJd?W=NER$Y-lDPGIpSo}}(Cr#6v zAIpAB@-@%Z4yoOv=F{rusz+B}RQ+mxO0;{LHSN~4wE5-q4zks>>_1tZ@8hq`&Pb0e zURQXb`J$${X=I@)-lyi|x_xT)&o4;!$W71psX4Xw+v@F-H`0TfdNr)Bzoap3J~{0| zZ26MdcV;#!9h&`9)>;_SJhORX@isp^ejxW%enNg|{-3#)$-B;F*B7pA+NJThro*!y z`6Fwd&Ohre&vtaU#-4yqVTbT7OwrKveX+rau;*4vDTx zj>+wl+dRKj^^EGZtCl7wCEMq=&s`UdDIVYSc=P*ySTZDfu`sN0Y*WANi|EkggXELs zs(3_pSyStV_RW+1vq}HlGs%SHw%qmkwW{x`nOi+Kxi}qNKX>`f%XV7%dgCi;tvj2l z;{aEmy;*#^dCR8#8&@=RY5bsZ-{zarqvERSp0(4eH_1Pdyq=$3*XBKA_z9{-^ zRPaU2li$}P%vpdq*ogN?K z<~8lTdTQg)cxrXGTvoWI@w(!;~zbXGr zG6UH@k?lZ@cz60{^TQ2)tnSuuQlY=wA-*<>qS^7^^ZESk?xE&(4Wk-nHTTTMyHk=o z@~2iGS$k>S`n4BVJ(%xXU02hbZyo=cJ(G?}kITCHdOyzXhIjwwCy>2&_PbF3{gKtB z+oeDGz2ZmW3!_t_my=DZ22}l;G!u*FLMO?@K9kwwpsl50^4DhZXnK8Nc;S{} zk{#)zXl}ebIX~GkJ~diIWzn3So8D8bFZQHPz9Q?E&CNb_e`2Tg>C?p)*~V~oLH2s_ z=wenZrYHCo(ed$G$^FUNxf^mj<^#0NzsS#CCTNAuMhqvq)0PNT+n!QJ2&rw67FX1$}W zl4rsFar9C2K-?;s9PQxSrESvtvh!U{@=>lf_e=bGJRsRHxjTL;x)uD-W*cW0WV=wi z9Z608IJMsW?m5;Hzh{s53#q<8@;7Cp(`V8)*_YXPziD(5Qa?U9IlkXb$R15QraPv; zq)%kG_?@U}hWLf)$TarPyECFe(KYT1|3tQ^cthdV!hUJwHjW1;M<&zb=cCQtIsR$i z%Q<%(tCuIqtXEPEb&1osHaRJND0<($z&fW>b^^NEpGy52)($&FJ4TsX;krhfM4hR- z{z2t`s%zoe`WDc4d$x^R8QmMd5%qQ(Q6E0)zscIAs|v>zr=~kmL9R>1yT9Ahol~l2 zzi_*{Tl_ECzToHt_lNnj+|JZgA5jf_3e*vP-|WNU*M(Vyd^#iROZJ|aJeRm+`?zCt zoL@lHzJrSTFjgV=y3d@$LYKOi+!w9~)xt(@u)i?dHG48^&&umS{}I*W4pBZjiWNwk z=#S{-=m2+rc5nJpxF??QYrUy%UBuYsPHdjRkgw`r83_)+BBN!UM42rhn)9zMgO+Hkeara|G_VE z4@bvDt)q9MT=HtNC@#2BS)25lVlM4MG-=0rui(y&x<=di`r`Y=ef%MDP0~MlE*qSF zUF@9hn_cO?iVI1W{E#e6c28b%ZPVq=|0!IV-533f>`P%6pB}yEcgxO9?@sT|p2r6F z@XOL4idPi}r2oiHqAq{H_jTX6b*bvFqE1@@g@fGhezO189T(q`j83*8qEu_DL$3Hh_8N%4gHHsbMxdE z|4`worhS_~Ces}qzew)iIk#nemmABvpoi}bWm{66H@PXS5*GN8E{^`~?(%nMZL*bE z{E6w)>FL?h?1$_gR^UIl6XIO5McmiDo^~sqU3@sJjoQRdMpNA#?ju(f&38w*cl?&D zV;1;>qtoI8;*F!kUFAp?uzXCbdT%>c>TK2kUp6dtK;e& z;sx0Rzj-n%e^=Gv`J3Z&vzbjh)Zesf=BlanpEZpwUhI2Q3!dqx7Y7tN71l4_kbau| z=9jsPrBOcJo6jQ~ zoE04uw<6j;j~x%p_GtdT;q%6&#ro*{{Ftg4Ro9b&POW;R`q$L;WtRdo;e0&WP@a55d0v>CaDZZa%m1&Bm(=Kl|^Jr}OLQ zv*gF*!`znn>yxYffI@TQB~4LbyW$yX3;&(lCVnkBDnG93^6LAlZb>%qHx+hle!j3E zJ2k$Z>U2`FOFY)C%Z@^i=&|VZXg_yA)~9%WVOIKdT+FYN|0Ej28uObdjlapAS@m%K z_2`Y_+{U#2>H2pYRyTcE+|7T+p2$XSU;lKrVYXkIHNVwxTm8JIXZ?rqtFCWxQd5uO zKGCFn`|2Nazq*d;WzC(N4sJ>d@4DOa*VPz{wUdT8DGb-z`; z>gG4!*>G2VyN0J5S2bUd9`94%!QYz>O!uLB7?w2`1~<2CzQ1s2c1g56>670!|8;J| z+}_Er(Qp2S)Hjc9e82It;>6_Ln!(lAM_;9XWPijHt1hX1u&!<0Y1QAycct^1&TCp+ zxXura_ef^NjnR717jC7yBl<9^_iq*kH(Xj@TWD}e@~5lz54jGx$ExFcYN7HSM{Tlz#xIxpN&6gGraAR}dR{vi8P5$@fTr$F- zat|pw0q+Y z4I4MN$XZ7yC#U5Pt-hhQXNz^~-mYp+j>{chHN0x`q`&Wy-jV*9UG4Yt8?a|F%B`lF z+SecM*JlmBnLjk`m9p0ve;Hp&T&|D*$Uk2-Huq{=8&}0gxC@G3HSXQGN#WS+r)+L< zMIoO(5Ivr(pNxWvdSXyh@z3UWo1ST2l{-8pJ&M-3b#klX`=V#qovZS_$#%P?uTxLGleSGSOwaMxMi)g1>-*}o zpYQHQX4e!SE?fwPGyV1MCRYEKV{vVB2PWIbz1Uf+_ItBS`;lKs=K3?bE8a30$ZCI| z=m>Jv`~Do?FY8(CRd}rUk{?CJ`kmdhkEx z^4ny;c61s!=-Twa!sX42n+t_4(oxw>t~TB=M(TdOY*XsN%h@a3(;t#;hjgAwU-dsn z-#S3Dfe|f-DQ3Rb<=5JI?1jc3PO$3vsz+`+z6);n#P zuA-XTm742$D!=2{=^X1nWH<6*x2vC#jxJ6rj4X6Zr};cB-dMbW2o*M0kOkTz& zwsl+khU_@@IuG`*Wn;6h?)hlTc-?3Z-b{Mzg3AE=`+@JEZ-xef&voJL=4zqI)6-+^76H{>W@-dO`8Z;)SWtzIRu~7bPbq z&&0n_Q|{u{^ZR9;vvsrXS)c6X>=WPQzKnikXZ#s<6;E;v-e>K}TeoKS`h>mFoBc)Z zm*_Bd3v+z0jc$nk$tqz4w&v4fx-N46)%A)8$34gz8~bbghSbmt{ioTf>8Zue#e0e~ z(lP$MXzk=PYP}p0u7m5G;RoqmS-n5s-QfC0m&MN|FDL(^(mdWDoxPSW!?Ok!ZZ6!+ zuExLP4U$XZtD}U-*p-#d9KW`IJ$pX;p1sjw?9czin&6q}GO8cvYW@D%(sXrtVD>&M z$>03o=;?Udr;D@o(xrv-3x^cX%|5}muEw|bitc33{bnEe zbF!_nldIRN+5`j_CyU^14P(MJuD)cxm)3X9y!) zYd666&rV7QrB|?DwlG^S8kYMxH!NP^FY>SW6a0#7R@TGs?T_`BvC5s`evXF4tD;k* zzeT&EwJWKjo9Njy`?a`1aa(p>Zi!mQS49)tCvNZPd+MPxU3b^x-tle$A$P&NQMV-Yz~L?rP$9-MiVW;?~6_#iOz>{7m-> zus!teKy7oRdzKZ&mBhj`SXb@Be)2L`h`(d~+&bz+RO{>Zai_cO-Q&dbuGyG$yX+u; zf!l@rH;*05N7<3Q$o=6D<<#N$tV?!kc9H+m-ATOvfxY<)iLTZ7*$}^qs5GZ|MS4^A zsh{meMBU@v9jTV-4ym#cO^^i&#v}j)_*J6dtEo(H{C1C zyJEC^vVU@Ud^aZr`$1DL--fe|H?vpMmx}inUrpEMM58@>O*=Ao5bLHB+-M?w4`0Ay z-p*<{3+NfOinos+pwfPoTKVhj;Os}@<-Jt&N3d_X(#^qlH)Gv$SafeRm;LzJzuYraG`gvL$=N zc`Wu7|DL-)S}#5>-ak&_>!a1s_Bs3Uzh{e(*$~!wt6eS{8oj{2@gvy#o^B+R_w)z( z<^E;2EmqzpzB67Ce;3b--e9%zy#J2q)6Jg@hDq5X|0t`h&HahlH|&-@ALZk7qvts{ zn&jW%G-{PUlGCQ%(Yw(;@ow?0(P(t@2N9Cp&f>MjwycV#Gyg$OpT@Y|qn)Gk-5?)j zSEpmC#SR41JM85D(+!P2#?wY|DmTpkJENja`)23*f4g?kX3H*#_CntQ&FnUudkFlckNLqggAw z2yJg?=cNx6A1mIS&g7)zbYg6y>&KelK=+E@3y42uduKnV-==4i$^Y&qx*4_)=|g~^>^ay&W|OpEN6$U21|R5v%nGL z;Wq3L4rA?CNB))nP9kHTOnk|aQy%r-6DNB4R-AQR>2Kkb?xsC#ji_73|iANhZRWwP($R+A?`^4-|?y2N#3U!&PS{sZQ0NOk!G)a>S8_G>ZoWp+z@!&SZCmox76Sn~~Nsy#9q3XN;IS=1i&oFSf3 zI$PXaW?fC`gFv?j_xKPcLmeQ{#ri{tGtV| z@RQk}`4C;aMrPj)sQ-rc<(wVw<$fX}v~c_5OBW+^tdSgb6})$1ujUJ4#&UAzp-5pq z=f#7tz_r{rXl{<*k~8UZky$mX-0@Thy*c0Q#wqGD&VZl5j>h>JRE(Drx6VLkb!3jM ziILy3A9oiz`5AlrgtPdk(E2o@>48vrFXzDr6Yt-|y7$C`wm{Cu;DrYe7an#Ou&*?g zTs#m@It>56iO4$}oxa7%>2PFyBInap*uXg7mR+2V&^y|{$tm~xX#WxS278a&xcl(Q zEZd2FpRb6TU+_B;`#lZLN5S1R^8UuYqdy9sFJaEHz&U_5TBG}u`eYOQV*$4J2)q^i zZcwo!XXN9t<#&i>U->sUb-xf;*JAs-va9$Inrz9ed$6>-Ifs1@p0(CK0V~`ZE9}UL z{S%yPujIu15-jo(vd~t<#Yc#MVzdTMZ=wVJKzJn?>nqx8P)s_ zWaKB&>s{2hH?WVeGTRUT9mJ~gYWCcILqbo$^$N0Gk(1+B&e>LsD<`c*zeKk^#^uYi~c60wllme z!U}4g>b^(ZZD{5);?o2?Pqb}@-=D<$AnyTZFZlf@5owqkqWw(Q&V7$YKKJ|Z9z;r{ z84cZ4(K5K&8v1(hro@+Esz#RUk}o!4<~c;O^*M2^M+!d^A@}v$VYOfSz1{14hcjzW z zGd?>Ff9Z(*%wztS*xj3W{$SslNO=ufc^BPm1=gwT9`mjSaj9>#1W$ey3Eze{ea5>P z%l)=QnT=fn*X!X+n{oFRG5SP&aWi)47xKP?N80yO$L-|)hFqJO^*HpO3J2#Szb&ze zN07H-a~5nopD2E_=Zayy*1X2<&q>bO<@TRdYK z5qmO{{yX2tplT-BcVE0^jQa%%>`y)O55JS&1&N=7PP(%O8H}zx?|HO_s>#58l$pah zzxar!iO@fntZ)eac^~{8i(KYnk30Lm)OuU`dx*!oBaQCVD&OKuZ!!M||0-U(Il1K{ zBKiBgoAMymcp0nyorq--Z}A+9{y#=P_2k9xc*A2TXEBN`DY;&Mg!S^e?gH^|g$vv#MMxfWV@udaS6PF_4otg6> zGHHRlS|PdZh;`GD_H(=ob1hn5fW>@`hsV*kR0ivl>pq|kx)wX#4xP*5zapx>g2vjr z3&r5n+y zn%G*vOU~upoF~Z%UlQXEqSjmte>XFBF!k86)WChv%J;{$IC2iU{uhwn#NLl3s}IAP?xLrb*@u%O{>Bdd zQ|R!4~dw$KuNzl<*prRF{cjj!^DqRk$>y;L2&MU>jNr1e#J z(`i`g46^a1c)=me?Mig7!yY=J*$?ow!+0wzCR>~W{9~a>zo(FmRf+avKWj<0EW4T* z^rHKnO8HOx|2VSU`(&&--f^0nrP+E&_Cxfr4p1Xtx2N)W1(|OKUuUBGe$;=pQJ-iE zUbs7y9n8p2;QK&-39+$2Hob~mbS`}zsL;OmHJnP`0Hv?;{^5`4`vBtl#>B*9;o%D8 zsY+lkWcM&}?E_3DZb0wK)+_g$+Sa=d9j;`&wPoc+>1;$ zrBc`!z09ZYP^z3kSkXqnIgQ_O#I*~E`eRrtJV~|pGG5k|=%m@dVBNFH5l_MKYB<{+ z8~q1ytq-qTL3~Gc-2%<8$5TD_^%fSlJX@XZjn02VQvW21 zkMq6pq%PEu$CKqh%j&c9s2qkP_3v3-+=njCMSFvIBd>tfzJNb1^ao?#heOvR%1>#&+p`0UwO|90?}2hOp`_+jjG3u;reXL7k;FVgsw*t+A(#(91V?d^h4|hEy(t`WDob=KyhAs;EDx z@LIEidW&dqC-$=iR?hwvl-5y2d_!ixn%d)Y;_F__8Up;btQ$^%u0>ehICA1J?BQ@S zz&XgC6AEzrj0JS1e*|%34kzkYqx(ykAF;c4HnMt}?A*c4AUEv=_fNw0`&juA?d4>HV>gy|ek1m_mE+ z9A9`3sU1gTJRfUZ2aDJPi})HoAI0l`!=|=F20LLleX+9^)JwC_(4~xip|nmq2l)Gu z1NWm|*_1Io$Wqd2f28;z5$PJ_s;v4uc627S>m^X}Gq^iZA)HL_sd(>7=!&uXW}?yy zP}c0X#_H=>m)?hd&L@VQMZR3-6$N$$_Mg~WD=d8nbhSAaxUyt_jp*wxR>hl8BOgh$ zorw)@hh&?O`EO_{2mKDcWvmB}r%sV)^#Y5>nif!%|G}6g{QW_+>O=31toKgG?+R#e z6qU-CWSSSSl}1(!U69dARyrTS%?Py73Ty8VM@OT-3!ti&=-8gQ`|{lhEA2$xHU?|E zj7s$rs)m!$=&o2%2XwX(e%2ZvnnuJ}%nEN^c#RCu1H`WJT7M5zRcMlEBl=oGKZKki3i&PLbtEtp6@(Pmz+IrxSgC!Y@~{j@uph?a08>z_60~RBN%lOMW;N znLmcspJAQ&HvGR#B|QVl9ZHZH#TOQxpgt42z@tIi5g~Y&%k^W&&@;R%w(Twa2N1cEpSiP{k?UBspa61ml zuE7VtMJ{Uty9Zt?_-|1|-A|?YOerHp%w8Mb+3RN~;ymE*gQd14&MN-hf&^aZ(_BU%s7hq95_`>pV}0vs!l*8ZmN)fax8S!G?i#iW`)xV1Q z4Ll>z<2)kV=E!n$s>+zrJ>X;=tmhMCx`dptEB<{v_P-Uev;m3MV?j-1E@hRUng0Q_ zzJ-N6Or(DgdY5A*tC88aXys)p#!*;6fl7EdHhU~N;B=za7ErJX{mdsmc822hpmPxR z+!1SBlQ(fJdZp#5zm;9|EE-G>iq@Dj|Kjx(EJq@=y2l1g-Av<@MW9)QSV>-3H4Xgf*>8ESmux^PwVxhLv2aptdVIU60=;sQC>) zn1=2gIj}u`Fsn2=g+k@uTCgub&rczL_eu0MXGAQFv<{-k{hDccK1QDDMHa zz45k2sGN(o=DEy2MY#W%V`!4~-1 zN}xZ2MU6#2if;+ID8`OD1E+?3{x)>Ki(V&!ua>NH5i679(9c?UtX5}Jhz#q1xi`;> zHcOyI|B`7EJ(HRD2@&>Ba;^4BHb8Hk;A<$ftQ~i0lp`>+XtveuwHu=>HI039xqs!}i!j8#s=cI~QJ;LS2#H70})T zX*XdH3y_5R7SJbpdLoIPn7tL0ZI9dsgC&newnzWkkN+4PkK>Dzft07GE8_-(LpzVt zOR$@WU9#C-h&F@aQLBLu(fQa?gliz;b|MONN2YDzp)IsmQ>Tm}evQOJ=3v!|Kg;o> z#*!y3N3s*J-5Kb-H=dxnYzrdEmPFZ2(ePS8YC)78$=d3EAmor^7qrz5J!}h}S>XN# zSuO!uCnTd9P`kPtGOh|gSH(3Nt$xCoJd|$(-CNPS4l-(sCoIIHe!$|!p_iYL?o#}} z2@BJ@at?hTV-?>*t=7L=Be4UCKen&60a9zgRz4;xKTK^u9-nN5Wz=I^y}+(qunbFE zjt&xNIuIW`h;`IK*sC(~d?naw?~@0B7` z3LolK=J^?^uRymBDfc7F97~klk-Hi=A4l|k8@qV}-DwB9J-XHj$`NS3j8z zE@WM%>^2k%v~$~$xH_Jg`~rNvgZ1=b&LQ;j1~hWjZq~Ar24^6*&r4OqpU^jp?*e$& zrxBP_fiVs4TVnZ}Q8_9zDa-A`Q)@ERH*ozTU-{cY=BT3E4a&Npkw2hqI<(EhlD7c& zF~rs*sbF`5{u(OwH_56ulP|_$Gul~ihc4HHw!T=Z>iLma> zw%vxgsLQhvL<_Pq*HJPdzzgmDN)Ig8^&8pT^U6IeG zU|)+kGaDcKh`-rbLQCkc=G&H@Ud+|m(K}?!m%;IC$!ez|iDt%Ck?Ve9)~}41gtg5@ z=ZnGAh*fNWrR|P2SFtPf0s8zDYifzEJHUHqG`*uZh;myK!e?|u}zSts@{&s z&_nqTctJfC&^ky<{nC4zQf>AlaK{083D|o9y9@ESO-TavNKD$C1TD>_INt=Fli^Zv zcrmoBf_`O^2JE~EdufR^XtmNBn;eAIwLmMXBLBo9C&FhNBI>qib1iym7+r%k&M5is zdQiR=c-upvdFX5++BhVq)z=^3s-{nruY+t0Kpb1jC4xB!Ic>sh{X1ku(S=B3CL?A- z?LuU$c+;Bx&QMklowBJuaM~NMZwCK(bhi@jJHUH4{#utTcQWm&q2>WY>(h0~I6ES@ z)<}Cc5%oPh^i#O+g{5u*PebV6x)fU!jb|}OYvuMxvMbzXM3JSmDvM~nT#IJ(6(s}F z)TVs38dMe40;*Jbs$!ZCcGY(YmL4O`0 zC>m-tQirq`04;~r4T1JPXs=M>a1wS=SMttUuxoxD-ZKThe@CNZ@ID=qnb%;>F00MnseXOxq{GzY6%j%}_DrdCL0I&jC+t~V}JLvMtp zw=7v{A87MnP*uCE#7}2rRRi??jOzjyie)h{<^xd?p$-juG@?8lVGoLZbID@b4OmIl z*#nt$M|P4xf{)Du`+Q{IfY$zmKGiq#u{K5f7StYEP5f5kZdr*dwab^*2U0|S*501} zSBG55LZtOg$YKMS--D4|vF4RfpiHRU6?tL@t`SzW44vvdt8vV(Wqda%ZjU}yfA@lu zDm0L=Zv6$#{Eo%N#B52f2|3i_Ba*0AfDQbuENQzlQ2L@TWwC_HWjrFEHWuPa4O zW$|_S>&^2#qRV)=ZZ6HQrd{4u4Hi|!OMyS}zdDgDyOi{@5!CgE(u9g_ER@Z_MkRAa zlEv`Y0ekn@vCblv0R3lptODD5;9Q$)Z{SoB9T!6RT)xUiEwBOUv;+1w01E2R<}|3* zE{z?(bmsMs((A4K-voSze4AzcFZ6Gk#Ck75;%(POTQS9Lr#)DhXuBunqy2Ggh^>B5O*v=!gX= zYAV04#glY87oPPt%S^^7f~ek8)Rn(2f!`_6K8ZVFtU$l{vWS`60=kroo1t4P2oLowkV+q{Y8}REh1DL2 zDRbq&s;`urGRDPNn)YXAmtx~|Xh^}XofyxZq^6a${-+o1m1yUt0Srz2*7Fs0Rlv}W zTQj4DSyjJsT~*1$J91ZDl8K_%9AFjk!@i|`fmV#*pRF=K15$z6HAqqMUKXvarF}S^ z&Q8LQ7GuZSm8gZbYADPjAKBWv;43e4F4@*mxk?JNz5YbbQTqS+Q{dtT~U1bqpb93-*@>PzW%?R0YjviIPo!~N8 zsy7SF6XlEO>qSKCg{)hZbf-Nw)ef`3-2g^evn)otWU>NT`DE;HKD}1$b)c_1vR9Ph z-}i!VHD6V?^4SjbbO3J^l&Rj*&gKH3&ji8>IB5&l8}aSK7_BRoK+`<>w92l6rjF39 zm0L_MnS%%Z3DvXENj;Kj1!mR3y%|+isxGuXke$mja@Z~Z(gO=tZJp7QLbI$qLJK)s z)vr9K)qx_5tX0^xJE{tx9UAYAPPE?AndeIMwipPC4%&%n{a@Oui7JxjG$_-)sxrFA zB6^o3+XmQ6@!QGtOQWi0i}Y*0ysvf1rucUSNUZ_LI){jnoa*y-&^*6XCu!}W9fK8o zn~{vP(jSXbWR+D)_OcOGo{E>+!P9v~Yj|A&U9wVJ+pWgpRQL459#p@rhGtd3TKhF2 zo9a>=t;5ds?tt>FGMZ$r7$}Q!P$%E4=Sg{D1u{|HwGztA)_UQGT}m-V{;f5WGGwvD zyY^kWK)tk9Q__SYm{yybs|>A}s2!`8%#g3B23Q0howBwm>8l;r4t#4$yE5|_p-Ns7 zQj9HfwKp@S;{!VHP?XY{MuI)4GSU7)HGfHoCe_i3Mv7^Q<{iN=n^8>Cszni1v9tp^ z=~lA#?$9SsYDQ;Ep{)T9TLMinqZS-J7@Htjt6mmCgJOWiYE^tInWKtq5j-s~$uBEK zFssc}ODH0%o?eUu^ee3^#>o<^(2c6nJTz!0O7TNAQ)jU1eFN=+H(?2?NFqkmFjkpB zRjF3v$|?>FI%yI9)lj5}BmK!sg-=#4-8I6I)~U+S+JjfjcKqqNiLtUN*{z3fb?5jl8PbXIh&;wu>_28#w^k?rZkN0OGu2yy{! ziyGx<(Pnp|Q+GvZkfc?ADeJaI1~FgdJVhkQP?=kFDPCmoy1XO>S%cZ6d{z7OvJjmN zs@ZK%3&bX2}yJAH^_b zaz!JZ)XRG07m5hlVNtapNlO#5{r%C&c<%B2lDlr-g&sQLbqfs{VHOp zW{_sJidP;|MWfoW19Vyb6J-tPOr9y*)gEjDj8@>zqnU-^R{d!E0~V8%-?{-gM~+c7 zpbAtYkMHDIArU} z-O_=0ED$SZmF!G0L-WfulQLi%G^*Ogb~TinwTe{6S0op8s(ckil=&i_72Q;OM$M;gsQ=9 zWg)APohpJzGR?Hh>$L{%fNWGR$(~fNDzmC?5FL_&;<Jk)X zA-zYY9#LjHHuAJ~P#|xbi`-Nv>a7z=T=}9cf6{~2XUdFv1FE(p4b_^8>x$=!+M-Jm zQB>6Fu;CS5tFT8^6Sf{vtuO0Wgw-CX@vpcjuGA`9&Zs_Z^L_cD#UFXPvX}j;Rd%GjE}mO*CvOw{6+n;&XFN6V)v1iu z@oh>PRrFT;lO4HNcp(@tu8hL3oT_X8_)?iu3B9G)GD&>h~ zUsR4%yR6Z2ylh2Mw>+oFDi4=us7{bhrEgVe(x>Vdttt$%=E@&c*(okcYMQN>r+BQ& zO4?IpZCOJ8D~%g(%6Y2BD zvfA9Dz2cT+Bp8aTvL&q+=mL~-NRs_-eLH16O zY9-l>Dr>88R4K>uDcoe0^K_*RZr zbteCjtw?H;p?1wI_L!V3c1iE5Ze>4;oZ?I)r5V|)Y*GF#ElaDGlR_T1JpvmcdsB|l zx<@?80}Vn%o;6#ym61+^EpLl5vpwN6-!adV6{$ylCvPyRn4A?!loiZZB^||Wtw73o zN|s>u5;!sl+Vd1d<-A(HnypA-o}`^Biz;Swis!Nhy-gxD=n1*f zeAH?xTR*KX;TOHaA_~mIl;;#}t!7iS79Zwi;!9kcoaNhsZ?>fspv7{_KC&joL+zXB z#9w-q2AcU=&1ezSxRb9rtYQVR(JBlO!Iz?k&e@bNWQX!o9X(uLrfJ{4CiQ_EY_V-_Q>B{j`A{SYDlSD7#6-o)GaAH>7FpF3FPue}ZHAOqGQ^S-u$RI4E zgtBRQob1Q6D+wyH$yODOl`$2y3Z_ifOC~8`45^u6``Jd`Y z$!#^gYPEP_6{chsBC{ZwOsyJG?37;ZQf0N%Kv~OcYtHl6ECIi(ND$N2`UEgO&BA zRnx20e6mIPfyGXv%`_^BT9%g7<)4b0qE6aZ4%QrvkiUs?#Wb_?;4K!JEpA%9Wu74l zg+-j(8r$~RO>Tz8RxyftX2-^<=Bt7*sTtiCKg{0LZ`6rzai(}IKa+=A<`yOLL|Ka{ zv6y0OOpE2JiEJG%{|GT#5|9Mt<%UN#BL4_+T=KR`*)$<*FsWE9Q1ztMsA94-rH{o% z%LUSyMK{S*zG9XvD(y}(m#s-|Ru5TDkvxT6k=G)p*_c^^AZdj3B`cGxq(}V;vPECB zJBzx?`az%Ov#MznchxVu*1a%U)R2uB<%Zobn8d6KkiBV+$y_#TwrcgDdnzCYj&4=YF`dWQuQj_Nx?bd4^ zYqn#$R%{YEV~PQnyfTR_yZQBNW2-}l9)wwi)4xb(w6B| zm<2&CA<~;nY__Ua;nmlq7e))d;gl2=ae{<2$G(!YbY}844m4lbrCXDw$<3r|ehX;& z1S435T8zG+FQdw|Bri4Z(zsx&W*BOH>JK7kjjvV_5;Xfz3mW*IhN(MM2%#fbuI z4KOQvO>XLubWQ3dqDmA5+Cm&LdkH!e9J|*HK`3JoO-7mM(8s7U9hk*gTcFvl#)oN9 zaup8a&ic)Agh$?B78SH&@l}0>&*U7eTT+v>Ol~1oNLr#J$keoAd>DVhUStLO8mF3L z7Nq&&HN+xO5hAl7*tnn{jkaFvv!`Gs8fEmG)++5>eCT7p;zRM%xKX>&CCQo8Bm+e< zqtYOoh6O|V2_LgJgJ6DdoC>36n+60y88B2e8gG3zGw>jOmDiL#%x=u0gJ&9UvviYM zs33#Ii!Sjc3aw2PD)tDreFF!s)a*)p7~INC5&TP(1lq-macde2 zIof>5G#={WK)Y327Ej84V7!Y5VYiiKutf0?tVgmC*H(4Qk4-)nMS^FkP5M@=$wPK% zdDt{=)?n0|yvnSv|cD=kiXQ>*cBdM|qd9efOm?u6U8ku@1@qC7+kjj@lo z47xSwY6-o%iX)SiwVSPoI%!37O*`h(@)xt1V2#GFe$``gH`#<4WsD{v^@eO{b8KE& zV)ks@2(IW8B_?rUw)ysKc5ON{TL_XAoPg8XOsAS>@(eOnuRdigx+{MK$9y4h75Em_ zLB{6A<#@Ly4D$t9OR!FHFPxQ<540OUre{Gk$}J8Bx&){4D=Mwe_%dz;(JaRFsWBQE zW}5y5zbq5Ou36zP_-L3NAZmm@dN#S*lYTYUAca{Lb@e3L!q?(}W(Qch20zs(J&9gP z+w4LRtTjO4@&A2H6Y4Rl1UblAgG@d)U-~P5q$T6IJUjTf#*6kqO_?jBTfCN~U>Y;( z48BRc%)1~3o(0pOXog{`)KS2zITrIxn?Y(O)3Q`F*Zz!i>o?dMWfIY|`MEF}Y`dx_ zNH}<=QLa&z^#osimPLc4jU#bmzEXy3Qn1-!jbM0<8s&V`m|@qwa0-I?fN%$i1q#(4 z#v4w-4Ax?r4m6m21VtZ7$aoT`l`||VR`v%9O-H5aCHVv@%CZSuhLIYfPq+q*dN!G< z-{9CtgAjNK9E1pK?I!auE8sF|+I*wFgu3*jdEukAXTTYJMQc)h3|6o!8x^=$tGvNl z1=b`RcoPn@mXK8{Els113ZqR=Hs5?*z8$y~2E%4N3YT=G_K=y%ROn70jWt?>x2ru+ z9jFW=LR}Z=ve;s4gdjapD4NQaz9t@{$;MbufNl~D)R!$y6zXHYMw3x*^xF*MUhSbz zl-S2)WcDk%l=*eFPvFYt8aDL=&k5cl8q_XM%`$96z-;q1!e|K6vwm?`o^PW9L~FM; z{n{sRXY$cljSFM#YGbTNbe89a{vZ#{G^%ZksH@B)HaBQdyl9+$jbGEL^&5wxOI)p~ zH_@V5`VA7Ogb^q-7^XX;G2l~=!3><+98q9YRcfS6p+*H=*!+-(j1Kh&2*DPNPJrt!04V+rP&9*tAMQys* zpXoEq5iO>*a-<1uqE25MD<1U;6a~7h)!L2svQ(|lq!aMle1lfWlW|p!o*E(AOmg}) zyEdAvz1(L$RmLnHgx!91r#_QIXcczNQ(v%B!>?A&408;wd68ggUibuh%B`VSzGe4@ zK~Ta+(1W+FNr6TMsts?5-(j|mH%Ni9GHj#EbQ&~hax!U#dm9&^7_VjO0(WXDOUEb( zl2N-^lcXn3E9cvD;6U)K*RJ77ZDCB1mHO;2+}S9bANp*b=_KgYAc(^N*Ys+0Om5}A zO3Xnj(n|T>FdEkZs@m3!MnNCOqrnd|#ap>0+!>p44lQO*^^|{D^;jDz#>PA^vE#!M9)I!7$jb*-Hsb$;wS`YsP)y6c!s>Ij`(# zHb&UY<_uz>Jv>#?BTB73P-t2<4g|@lt!x!+|^r+cFixt4${|{fWhv}BSLH7#bAXegA?YJp$2G`<0|h> z>!QwR73KO=YURJgmuNK%p(R{PQ~uu^$u8Jjpv2$@+qQXzQTG9-Q5Jp$)p~8Vp8wjX zw(wD3fK|zj+JqtehC5vYWZ}`XKDrCE*;9~)K@dFosYZp5&DT@lSoa3q;FTeU9$f=1 zCa)khqf9UZgmBdiyAN|kQ{~r2XpE@T$F700vV=sb`ZXia5jYHO)^0oncx6Z$W$=wE znNrfKj+*XW`FyG%D0*<29#jrJ+q% zjnOF6ukQ3$sa0#Swm?O|Z}3Hn^#sci$A2p48vQVZBzybHm5u@z$-_Oz;(c*Yb7*Ep>n?U3XavKVYKK7 zTG9AmOPXETZg?txHQHnu@ECW2$1v8$7#{uF$KZ!wwb}Upc9$*&H$<|~uU4B^Id;wa zK#{eUDY56!8z{5q05kL&ci}g{4^-$`_hGJ{b#KptT)s0HHq%-)+UA#GsKp?Z$5?M@ zwRX`N#_DQtb!Q*z4fyTp{~8r2Go9IQ7#pM$##p;y3nMKi2E0LndJcF)Uudy0#)HP# zbAW0VXPE6b)J*}S<^+zzID@TLaUEc(H&9fGFHmBXX>^F`Wtziyqp&j26euz{_GEJd_Anw`P5))6f#&kuaJME^ zp;gyPY(aaXD8$J?rOl}v5oQJ%RYD7WY75p7=7zQ~Mpvt6)nlK~C;H0w;;$^Na=Sqe zb9Mdetp7RouU28Rg9PnrvnpX}ROnN0S@t$R^oFN3DJi$wogf5g|C6tPS2&Ck8>_kH zzR(lqtH;_5hy9j&*KF0uHOK$;NfZWnmD~ne1X-VerxJd+FZT;u;90%a6K30ZdkT3x z;I^4I!hWqszk%ier_v%rTP0SFFT<^b8Ab=XbggW&d&3#12u}f`T`PYB1f#)v%57!N z)MhdcV{}z(SqI@+*p0gX$&1Z4xs-eDF6hvx*RMvaZ%v8<42>|l3}1NG)q3pLXsNt6 zcm_xC4Ugfn+2!9#+-lX>(Dv7>^_J(_U64>&Uurk3w&P(vf@ToKakvZgRYDCE2T9mW z&C#b!Mc_~rgt2AZcDE*6d#dCy$jzWu_LQjzcr?3`?n>C|F*?;+sna!SD^p*dT}gYn zwUX;{YoMUqQigA|m3t~F65ciO=-JxVY!8wM+?2HyXs^Vhxx%AQz#L|V=knFY*l7J$ z?iv_$8zHD`(iUjcEWr!UmGY>RdKhI;!uZNr8m(uIEL)H$vbnm3J7F{!no)*QYA*d4 z6#WLNuGzQdlfezNsqcS-X>*JU{g!bAir1VK;DuJh66m(4%HJ?eGGSiS_!X=p*+_hnGB6q&9v4s7PW;jp*O4t4OSSt z=IlU4nM;kc*2-Ulp%#-?z!9Fx?Q2pV=q%$YQxhm!bA}+7X)s#!TyFa-ESqnwn)|=W z*XRs04YS&U9q2A#3An2i9>Dw z`&h%gX0Jxt?4Y|aUswAC3(%8MP>Ii=2Fl9)m17K!FsRjf472V7U15}6^&4P>Yq_V~ z7uw2W!d)fYKwlZEMwMH27ydM++@q)e`KgqNdd$N=6?gHG<9_R|s;i~pE#~L4%v{jA`_kW!g+Ko5C2%qwuWEAK&?Uk{Y+cmyC zf6aEmTvKM&Ux}j(xjZ+}Ql>yqDycCD0Zy1{w1nBAH#{3AdkQnGEsPAUdbYOEqgmlz zqjVoetIs}?L!i<|hcUW_`!Kq)MSTH&xxaG6n)fy)%=qh7y#ZUeRzeLr34P&Nv+Q0y zrqwVe&=*D+JbMy;8&@7(iBIc+uF)=%Fv`G zd(v;YJwVYlw6009^;lnJk7<5Q?FSeEQg|*ySI?T%mSNi6n!Pq!ZQ)N40u&o7=z$L1 z{}qF^{C|a(334qrk_7L6>ltsCD-uPryJQc@C6)xL_)VLs{)@D)df!K!_2i2BPKsU+ zy_ZcVUE`m87%x*N@P9q3{MDr{;xC*xSGPTux}78!Q2f62`gQEzZ$AC@FJn1T{HiLw z?|PG_3G0hVp&cb~I#hK>C#^cIU-#*zdHZ%*{h9w%?Y+L8W%8KQsJxl}x;y(9JxzLK{6wTa zRiilWWfujX_cX+d@*Psk3AW!{$o4z!&h=JhvtC6p^QtEfw>;dh=KGa(681Q%!C6IR z))=>P{jXP;rocPhX%q4LGDX(aJ4JdaS^Ki>-)fwc^bA4vD)p7PfA0IB{sZ9Y17 zx8nqE_}-S9WdHkCA87qc3b-3e%WbE#GZ zwVds-Zsyx-TDhM6qMJq?GTBW`eesKy<236SZJK$4AFh}Rp1HV}>A~6wZe{VJDN&m{ z{8Zm5E(1g;`K(r8U8Zx_z35cjU(THgj!(1AJ63NU9H-&=hxDI~tcq-1S*?4!VeY)? zbOT|g6S_`}w9UnXnG$nqO7RDKb5S@p7O{!YjwO*7Q^dWRVHt2IO& z_NJQh?t%Vq&#N>9C$Z-~T=ik|YZqH4&M)h&O=0S*72O_u@_mh+@Ye1vf}XJj0H%|`KZv0gle*RY{{515GXN)UR3idY3)dLfb`ftzg z?+TBYBFyHTFLh$`s_nlyIui)Ey(#iocc!m`bHu9aouoh)jr88rz!6`c%FdTowe7nT z7}nKjD(hR<6)~-0==|Ls?nax@s*|aL=kzu~w-dt-*wELZS}1xqu5b0a&@mrgbWTlA ze;A^v68Q#KYt}e*-1o+(6q4!x+oM~0Ao2EARZHpTHxP^cc4aVC=YA(ZA3W<$e$|}A z%2iG;2Yj0^zKT_I2DiV9J1@L(!%yYaO1Hylxj~oL}eG@%n}k-#qNauc~;~`luOCYy8d}df4C4enWKD z&7VD=)To7|_S?xidz^OA-3J=0qw143nX#xBJCyaNAXC{2xd_*PO#V?0%U)kLxZl1- zQJ{U&Yv+7)H=j!0vZ$PND3dYwc-=w{=IN)auN+qqy?fc9JHe~h6z*kVzIkbd#9r?aCPTyw}LX@yqDta)zl`<;3Bu;y@GI&&KK%5uB!&{{t6)2BN&(d#CgD!qPG zhx6MCxgCVOw*5yV*92wYk!HA63EmeTlv6 z@UMe)`02OPnX}cn?yS_g#Z`8vp{E4?@4oEYSr~n?aufjzV1!Ey(pzT z%Jo6VnVTg~`@gTR`xK4Vy?5v>uZMCspv&8Heii73KwMP0SXL@&{wMtZiVF2q zyDH#c2UYI+o-t*2R=-=P&yV`m_<1@IJh;m>r}A}t`*VuXuDia}$gQPFZ#O=SGrY>) zOSdC7MY0}QWJa%lsP>_UtH*A&-hV?gbGQ_Sufkq6wC|j3ry~moC+r(rz3bJOf@;%1 zc^R5|ec%3_0F=+Zknk7RyAA#O?n#~FvPDm$DckAGO1F-?v$2LsJzV*{0pDGW+qrQH zI_K9!-<|%~D}~od)a~hT`C#rZ^uCxlz7sWF?-@c(&5GJD3yk*|OH_Ky!0%#mj1hKu z2=<+Vew|@|Mtys~o%<*HR#N|-i78YQ5l*h>6SL0!OK+li-LG#|*!QM7!BoYTdpB>k znWp*alGQ*hq;>}r8f!RRlw zogqz;IMl$$A?KNz z`BrY^Do;@iU7{3oE)P#$Z|J5;Wt}GO?-MQ>Ltd=2tTK0_oKtCHyF;rJI@SNZGu35H z@#nns#CFD4znQ@Jcl`Em1{UY^ZqM7OdtE^-Z^BjTr*=(z zZ-#E>AL)NLn8W4tZ~Kqn#Wm-^DlWh7aOY?|J1lKD)c#?6hI;ZR)IIYaH?$+y6NQb@juGQ9C^Q+=%?! zcpXA}0v%mnF11yL8{e9_$lL30_SJE!mw6TLR2M5lm+J7W*!6DqH)y`z)vju7ZOUwa``^~UDF*+Klg13hEKcqggK{`%YwZ^-WZuJCN`zJunFPhW&H zTCJ8R`IN46@pm>E@|z$mdsCo$oqY?PMr6F{7?pSOaA}u+_o;uU{)xOd;5)yr#eF+h zZ*dWOF;23paKFVbuBpHeQB|eC>YFgXZ{;%AbDC~)??|+`eI;pcCVEqIM!Qs1`8wlL z=W-V>dbME1T*qiVaxy2-%{cbe|IV-45Ywf$y13TA>h-5W<<8VySf4|RxBn9pDqi_c z)lI}@|M|pQCj~la^cY{fXY;FurQ=1wCAT)U@4q$QvxXm|Dc)V&RwweFbyb1cerj1+ zebIGs*7X+QTQ-O8j}F|6VkYude9W(-H7c8W(TglYg-6`7O=#cMcU8qn%eFIR=Tj$L zPf)KN=F3?2Vp5};=J!sV1B6^~VZUl(>#wWk+Z8p2i_$fOdUxgarXm8=>#GT<=C+Fz|FLHMa|4s(3Q@m4&@^1!28o2ma zEt~GQ4#IOAnhMC_i4@? ztg}`T5gr!j*WKO_@>Jex-<slSMyC*Qm;$Lv+apSM!gb57G+xl_uR za?Ej7$?>gRg>}9jIox`+cG&%_HNV-^7cpmi_TRFfwJPl?pj%B3zoNdSaNj+Sx9=&a ztsbLyvknzA);E6dW1Q63owL6eE7jJwvWc#iw_F9Jzh=$jZ?)-y@yYS_m@=PRj}hnY z#`5|*bD}AclT~r=PO7fojQPH~nR-pqozP322#;ZM`tF^2$WAq9-$Ey$9wxOaadQ5u X#|!72s?a0iC$3v6A8Qqz#MA#j*O}?@ literal 0 HcmV?d00001 diff --git a/homeassistant/components/voip/voip.py b/homeassistant/components/voip/voip.py index 2eedfcdcf9b3..3fcc2336aa38 100644 --- a/homeassistant/components/voip/voip.py +++ b/homeassistant/components/voip/voip.py @@ -105,6 +105,7 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): buffered_chunks_before_speech: int = 100, listening_tone_enabled: bool = True, processing_tone_enabled: bool = True, + error_tone_enabled: bool = True, tone_delay: float = 0.2, tts_extra_timeout: float = 1.0, ) -> None: @@ -120,6 +121,7 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): self.buffered_chunks_before_speech = buffered_chunks_before_speech self.listening_tone_enabled = listening_tone_enabled self.processing_tone_enabled = processing_tone_enabled + self.error_tone_enabled = error_tone_enabled self.tone_delay = tone_delay self.tts_extra_timeout = tts_extra_timeout @@ -131,6 +133,8 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): self._session_id: str | None = None self._tone_bytes: bytes | None = None self._processing_bytes: bytes | None = None + self._error_bytes: bytes | None = None + self._pipeline_error: bool = False def connection_made(self, transport): """Server is ready.""" @@ -161,8 +165,10 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): """Forward audio to pipeline STT and handle TTS.""" if self._session_id is None: self._session_id = ulid() - if self.listening_tone_enabled: - await self._play_listening_tone() + + # Play listening tone at the start of each cycle + if self.listening_tone_enabled: + await self._play_listening_tone() try: # Wait for speech before starting pipeline @@ -221,11 +227,16 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): tts_audio_output="raw", ) - # Block until TTS is done speaking. - # - # This is set in _send_tts and has a timeout that's based on the - # length of the TTS audio. - await self._tts_done.wait() + if self._pipeline_error: + self._pipeline_error = False + if self.error_tone_enabled: + await self._play_error_tone() + else: + # Block until TTS is done speaking. + # + # This is set in _send_tts and has a timeout that's based on the + # length of the TTS audio. + await self._tts_done.wait() _LOGGER.debug("Pipeline finished") except asyncio.TimeoutError: @@ -307,6 +318,9 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): self._send_tts(media_id), "voip_pipeline_tts", ) + elif event.type == PipelineEventType.ERROR: + # Play error tone instead of wait for TTS + self._pipeline_error = True async def _send_tts(self, media_id: str) -> None: """Send TTS audio to caller via RTP.""" @@ -372,6 +386,23 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): ) ) + async def _play_error_tone(self) -> None: + """Play a tone to indicate a pipeline error occurred.""" + if self._error_bytes is None: + # Do I/O in executor + self._error_bytes = await self.hass.async_add_executor_job( + self._load_pcm, + "error.pcm", + ) + + await self.hass.async_add_executor_job( + partial( + self.send_audio, + self._error_bytes, + **RTP_AUDIO_SETTINGS, + ) + ) + def _load_pcm(self, file_name: str) -> bytes: """Load raw audio (16Khz, 16-bit mono).""" return (Path(__file__).parent / file_name).read_bytes() diff --git a/tests/components/voip/test_voip.py b/tests/components/voip/test_voip.py index 19b9806e41ed..6ccfae904e8f 100644 --- a/tests/components/voip/test_voip.py +++ b/tests/components/voip/test_voip.py @@ -90,6 +90,7 @@ async def test_pipeline( Context(), listening_tone_enabled=False, processing_tone_enabled=False, + error_tone_enabled=False, ) rtp_protocol.transport = Mock() @@ -140,6 +141,7 @@ async def test_pipeline_timeout(hass: HomeAssistant, voip_device: VoIPDevice) -> pipeline_timeout=0.001, listening_tone_enabled=False, processing_tone_enabled=False, + error_tone_enabled=False, ) transport = Mock(spec=["close"]) rtp_protocol.connection_made(transport) @@ -179,6 +181,7 @@ async def test_stt_stream_timeout(hass: HomeAssistant, voip_device: VoIPDevice) audio_timeout=0.001, listening_tone_enabled=False, processing_tone_enabled=False, + error_tone_enabled=False, ) transport = Mock(spec=["close"]) rtp_protocol.connection_made(transport) @@ -262,6 +265,7 @@ async def test_tts_timeout( Context(), listening_tone_enabled=False, processing_tone_enabled=False, + error_tone_enabled=False, ) rtp_protocol.transport = Mock() rtp_protocol.send_audio = Mock(side_effect=send_audio) From f38d45151a7fcc9dc75f17f21adabaf116ba5060 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 29 Apr 2023 14:32:57 -0500 Subject: [PATCH 081/822] Pin `pyasn1` and `pysnmplib` since `pyasn1` 0.5.0 has breaking changes and `pysnmp-pyasn1` and `pyasn1` are both using the `pyasn1` namespace (#92254) --- homeassistant/package_constraints.txt | 8 ++++++++ script/gen_requirements_all.py | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index dcc98f78a773..15e1192993aa 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -168,3 +168,11 @@ faust-cchardet>=2.1.18 # which break wheel builds so we need at least 11.0.1 # https://github.com/aaugustin/websockets/issues/1329 websockets>=11.0.1 + +# pyasn1 0.5.0 has breaking changes which cause pysnmplib to fail +# until they are resolved, we need to pin pyasn1 to 0.4.8 and +# pysnmplib to 5.0.21 to avoid the issue. +# https://github.com/pyasn1/pyasn1/pull/30#issuecomment-1517564335 +# https://github.com/pysnmp/pysnmp/issues/51 +pyasn1==0.4.8 +pysnmplib==5.0.21 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 725c728607bd..592e8f5a1f0c 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -172,6 +172,14 @@ faust-cchardet>=2.1.18 # which break wheel builds so we need at least 11.0.1 # https://github.com/aaugustin/websockets/issues/1329 websockets>=11.0.1 + +# pyasn1 0.5.0 has breaking changes which cause pysnmplib to fail +# until they are resolved, we need to pin pyasn1 to 0.4.8 and +# pysnmplib to 5.0.21 to avoid the issue. +# https://github.com/pyasn1/pyasn1/pull/30#issuecomment-1517564335 +# https://github.com/pysnmp/pysnmp/issues/51 +pyasn1==0.4.8 +pysnmplib==5.0.21 """ IGNORE_PRE_COMMIT_HOOK_ID = ( From b0b4134dedf46db502aa8192f58a24b92f2bff4e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 29 Apr 2023 14:33:25 -0500 Subject: [PATCH 082/822] Handle onvif errors when detail is returned as bytes (#92259) --- homeassistant/components/onvif/util.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/onvif/util.py b/homeassistant/components/onvif/util.py index 6f03af3629b2..978473caa249 100644 --- a/homeassistant/components/onvif/util.py +++ b/homeassistant/components/onvif/util.py @@ -18,7 +18,12 @@ def stringify_onvif_error(error: Exception) -> str: if isinstance(error, Fault): message = error.message if error.detail: - message += ": " + error.detail + # Detail may be a bytes object, so we need to convert it to string + if isinstance(error.detail, bytes): + detail = error.detail.decode("utf-8", "replace") + else: + detail = str(error.detail) + message += ": " + detail if error.code: message += f" (code:{error.code})" if error.subcodes: From d9e0681123bc67980fd5ce0d96f0451be0574d22 Mon Sep 17 00:00:00 2001 From: shbatm Date: Sat, 29 Apr 2023 14:40:58 -0500 Subject: [PATCH 083/822] Remove deprecated ISY994 Insteon and variable sensor entities (#92255) --- homeassistant/components/isy994/__init__.py | 11 ++---- homeassistant/components/isy994/helpers.py | 17 --------- homeassistant/components/isy994/sensor.py | 40 ++------------------- 3 files changed, 4 insertions(+), 64 deletions(-) diff --git a/homeassistant/components/isy994/__init__.py b/homeassistant/components/isy994/__init__.py index 2f60490d8c87..e6e23fdf8374 100644 --- a/homeassistant/components/isy994/__init__.py +++ b/homeassistant/components/isy994/__init__.py @@ -32,10 +32,8 @@ from .const import ( CONF_NETWORK, CONF_SENSOR_STRING, CONF_TLS_VER, - CONF_VAR_SENSOR_STRING, DEFAULT_IGNORE_STRING, DEFAULT_SENSOR_STRING, - DEFAULT_VAR_SENSOR_STRING, DOMAIN, ISY_CONF_FIRMWARE, ISY_CONF_MODEL, @@ -45,7 +43,7 @@ from .const import ( SCHEME_HTTP, SCHEME_HTTPS, ) -from .helpers import _categorize_nodes, _categorize_programs, _categorize_variables +from .helpers import _categorize_nodes, _categorize_programs from .models import IsyData from .services import async_setup_services, async_unload_services from .util import _async_cleanup_registry_entries @@ -75,9 +73,6 @@ async def async_setup_entry( tls_version = isy_config.get(CONF_TLS_VER) ignore_identifier = isy_options.get(CONF_IGNORE_STRING, DEFAULT_IGNORE_STRING) sensor_identifier = isy_options.get(CONF_SENSOR_STRING, DEFAULT_SENSOR_STRING) - variable_identifier = isy_options.get( - CONF_VAR_SENSOR_STRING, DEFAULT_VAR_SENSOR_STRING - ) if host.scheme == SCHEME_HTTP: https = False @@ -132,9 +127,7 @@ async def async_setup_entry( _categorize_nodes(isy_data, isy.nodes, ignore_identifier, sensor_identifier) _categorize_programs(isy_data, isy.programs) - # Categorize variables call to be removed with variable sensors in 2023.5.0 - _categorize_variables(isy_data, isy.variables, variable_identifier) - # Gather ISY Variables to be added. Identifier used to enable by default. + # Gather ISY Variables to be added. if isy.variables.children: isy_data.devices[CONF_VARIABLES] = _create_service_device_info( isy, name=CONF_VARIABLES.title(), unique_id=CONF_VARIABLES diff --git a/homeassistant/components/isy994/helpers.py b/homeassistant/components/isy994/helpers.py index 53ad87c9ddc5..611d04677104 100644 --- a/homeassistant/components/isy994/helpers.py +++ b/homeassistant/components/isy994/helpers.py @@ -22,7 +22,6 @@ from pyisy.constants import ( ) from pyisy.nodes import Group, Node, Nodes from pyisy.programs import Programs -from pyisy.variables import Variables from homeassistant.const import ATTR_MANUFACTURER, ATTR_MODEL, Platform from homeassistant.helpers.entity import DeviceInfo @@ -349,8 +348,6 @@ def _categorize_nodes( if getattr(node, "is_dimmable", False): aux_controls = ROOT_AUX_CONTROLS.intersection(node.aux_properties) for control in aux_controls: - # Deprecated all aux properties as sensors. Update in 2023.5.0 to remove extras. - isy_data.aux_properties[Platform.SENSOR].append((node, control)) platform = NODE_AUX_FILTERS[control] isy_data.aux_properties[platform].append((node, control)) if hasattr(node, TAG_ENABLED): @@ -432,20 +429,6 @@ def _categorize_programs(isy_data: IsyData, programs: Programs) -> None: isy_data.programs[platform].append(entity) -def _categorize_variables( - isy_data: IsyData, variables: Variables, identifier: str -) -> None: - """Gather the ISY Variables to be added as sensors.""" - try: - isy_data.variables[Platform.SENSOR] = [ - variables[vtype][vid] - for (vtype, vname, vid) in variables.children - if identifier in vname - ] - except KeyError as err: - _LOGGER.error("Error adding ISY Variables: %s", err) - - def convert_isy_value_to_hass( value: int | float | None, uom: str | None, diff --git a/homeassistant/components/isy994/sensor.py b/homeassistant/components/isy994/sensor.py index 3bd870767136..5f36fed6b6ac 100644 --- a/homeassistant/components/isy994/sensor.py +++ b/homeassistant/components/isy994/sensor.py @@ -22,7 +22,6 @@ from pyisy.constants import ( ) from pyisy.helpers import EventListener, NodeProperty from pyisy.nodes import Node, NodeChangedEvent -from pyisy.variables import Variable from homeassistant.components.sensor import ( SensorDeviceClass, @@ -44,7 +43,7 @@ from .const import ( UOM_ON_OFF, UOM_TO_STATES, ) -from .entity import ISYEntity, ISYNodeEntity +from .entity import ISYNodeEntity from .helpers import convert_isy_value_to_hass # Disable general purpose and redundant sensors by default @@ -111,7 +110,7 @@ async def async_setup_entry( ) -> None: """Set up the ISY sensor platform.""" isy_data = hass.data[DOMAIN][entry.entry_id] - entities: list[ISYSensorEntity | ISYSensorVariableEntity] = [] + entities: list[ISYSensorEntity] = [] devices: dict[str, DeviceInfo] = isy_data.devices for node in isy_data.nodes[Platform.SENSOR]: @@ -134,9 +133,6 @@ async def async_setup_entry( ) ) - for variable in isy_data.variables[Platform.SENSOR]: - entities.append(ISYSensorVariableEntity(variable)) - async_add_entities(entities) @@ -292,35 +288,3 @@ class ISYAuxSensorEntity(ISYSensorEntity): def available(self) -> bool: """Return entity availability.""" return cast(bool, self._node.enabled) - - -class ISYSensorVariableEntity(ISYEntity, SensorEntity): - """Representation of an ISY variable as a sensor device.""" - - # Deprecated sensors, will be removed in 2023.5.0 - _attr_entity_registry_enabled_default = False - - def __init__(self, variable_node: Variable) -> None: - """Initialize the ISY binary sensor program.""" - super().__init__(variable_node) - self._name = variable_node.name - - @property - def native_value(self) -> float | int | None: - """Return the state of the variable.""" - return convert_isy_value_to_hass(self._node.status, "", self._node.prec) - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Get the state attributes for the device.""" - return { - "init_value": convert_isy_value_to_hass( - self._node.init, "", self._node.prec - ), - "last_edited": self._node.last_edited, - } - - @property - def icon(self) -> str: - """Return the icon.""" - return "mdi:counter" From 1f2da1217bb06348d47a4ae2b08ca248e29a179e Mon Sep 17 00:00:00 2001 From: Ian Date: Sat, 29 Apr 2023 12:41:35 -0700 Subject: [PATCH 084/822] NextBus: Correct iot_class (#92208) --- homeassistant/components/nextbus/manifest.json | 2 +- homeassistant/generated/integrations.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/nextbus/manifest.json b/homeassistant/components/nextbus/manifest.json index f475c94ed47f..b77ffa86f036 100644 --- a/homeassistant/components/nextbus/manifest.json +++ b/homeassistant/components/nextbus/manifest.json @@ -3,7 +3,7 @@ "name": "NextBus", "codeowners": ["@vividboarder"], "documentation": "https://www.home-assistant.io/integrations/nextbus", - "iot_class": "local_polling", + "iot_class": "cloud_polling", "loggers": ["py_nextbus"], "requirements": ["py_nextbusnext==0.1.5"] } diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index d85765aec4c3..b8d8b207991b 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3622,7 +3622,7 @@ "name": "NextBus", "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling" + "iot_class": "cloud_polling" }, "nextcloud": { "name": "Nextcloud", From bff7119230d99c02b8f0c72a4a2d72f74985b08c Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 29 Apr 2023 23:44:20 +0200 Subject: [PATCH 085/822] Update requests to 2.29.0 (#92231) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_test.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 15e1192993aa..7b235127e92b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -43,7 +43,7 @@ pyserial==3.5 python-slugify==4.0.1 pyudev==0.23.2 pyyaml==6.0 -requests==2.28.2 +requests==2.29.0 scapy==2.5.0 sqlalchemy==2.0.11 typing-extensions>=4.5.0,<5.0 diff --git a/pyproject.toml b/pyproject.toml index 785332bf5ad3..51fa26025b6a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,7 @@ dependencies = [ "pip>=21.0,<23.2", "python-slugify==4.0.1", "pyyaml==6.0", - "requests==2.28.2", + "requests==2.29.0", "typing-extensions>=4.5.0,<5.0", "ulid-transform==0.7.0", "voluptuous==0.13.1", diff --git a/requirements.txt b/requirements.txt index 3f24d2897ce8..72739ccd40c5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,7 +22,7 @@ orjson==3.8.10 pip>=21.0,<23.2 python-slugify==4.0.1 pyyaml==6.0 -requests==2.28.2 +requests==2.29.0 typing-extensions>=4.5.0,<5.0 ulid-transform==0.7.0 voluptuous==0.13.1 diff --git a/requirements_test.txt b/requirements_test.txt index 11cd25fa8c26..73a8e00df7da 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -47,5 +47,5 @@ types-python-dateutil==2.8.19.5 types-python-slugify==0.1.2 types-pytz==2022.7.0.0 types-PyYAML==6.0.12.2 -types-requests==2.28.11.6 +types-requests==2.29.0.0 types-toml==0.10.8.1 From 9468aed68970049112d0f2e9cd5c1f13e3ed97bd Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 30 Apr 2023 00:01:44 +0200 Subject: [PATCH 086/822] Fix MQTT certificate files setup (#92266) --- homeassistant/components/mqtt/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 582608335593..d3806044fcc5 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -194,6 +194,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: conf = dict(entry.data) hass_config = await conf_util.async_hass_config_yaml(hass) mqtt_yaml = PLATFORM_CONFIG_SCHEMA_BASE(hass_config.get(DOMAIN, {})) + await async_create_certificate_temp_files(hass, conf) client = MQTT(hass, entry, conf) if DOMAIN in hass.data: mqtt_data = get_mqtt_data(hass) @@ -206,7 +207,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DATA_MQTT] = mqtt_data = MqttData(config=mqtt_yaml, client=client) client.start(mqtt_data) - await async_create_certificate_temp_files(hass, dict(entry.data)) # Restore saved subscriptions if mqtt_data.subscriptions_to_restore: mqtt_data.client.async_restore_tracked_subscriptions( From e2636587f80f80de2581d6b84731dc1e630c62f9 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 30 Apr 2023 01:49:52 +0200 Subject: [PATCH 087/822] Add myself as codeowner to Rituals perfume genie (#92261) --- CODEOWNERS | 4 ++-- homeassistant/components/rituals_perfume_genie/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 6d9de8f19e34..47b76523db80 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -997,8 +997,8 @@ build.json @home-assistant/supervisor /tests/components/ridwell/ @bachya /homeassistant/components/risco/ @OnFreund /tests/components/risco/ @OnFreund -/homeassistant/components/rituals_perfume_genie/ @milanmeu -/tests/components/rituals_perfume_genie/ @milanmeu +/homeassistant/components/rituals_perfume_genie/ @milanmeu @frenck +/tests/components/rituals_perfume_genie/ @milanmeu @frenck /homeassistant/components/rmvtransport/ @cgtobi /tests/components/rmvtransport/ @cgtobi /homeassistant/components/roborock/ @humbertogontijo @Lash-L diff --git a/homeassistant/components/rituals_perfume_genie/manifest.json b/homeassistant/components/rituals_perfume_genie/manifest.json index 3483fd9d0a95..996dd1faecf9 100644 --- a/homeassistant/components/rituals_perfume_genie/manifest.json +++ b/homeassistant/components/rituals_perfume_genie/manifest.json @@ -1,7 +1,7 @@ { "domain": "rituals_perfume_genie", "name": "Rituals Perfume Genie", - "codeowners": ["@milanmeu"], + "codeowners": ["@milanmeu", "@frenck"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/rituals_perfume_genie", "iot_class": "cloud_polling", From 895c0be82c5109b02a8fbfacc2adc2c49c96ce5e Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 30 Apr 2023 02:19:41 +0200 Subject: [PATCH 088/822] Fix call deflection update in Fritz!Tools (#92267) fix --- homeassistant/components/fritz/common.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/fritz/common.py b/homeassistant/components/fritz/common.py index fa35b240d985..821b53f7e123 100644 --- a/homeassistant/components/fritz/common.py +++ b/homeassistant/components/fritz/common.py @@ -385,11 +385,12 @@ class FritzBoxTools( if not raw_data: return {} - items = xmltodict.parse(raw_data["NewDeflectionList"])["List"]["Item"] - if not isinstance(items, list): - items = [items] - - return {int(item["DeflectionId"]): item for item in items} + xml_data = xmltodict.parse(raw_data["NewDeflectionList"]) + if xml_data.get("List") and (items := xml_data["List"].get("Item")) is not None: + if not isinstance(items, list): + items = [items] + return {int(item["DeflectionId"]): item for item in items} + return {} async def _async_get_wan_access(self, ip_address: str) -> bool | None: """Get WAN access rule for given IP address.""" From a5044227a8b90fb6f3adb02222f54a323d80f2af Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Sat, 29 Apr 2023 20:20:43 -0400 Subject: [PATCH 089/822] Fix Google Mail Sensor key error (#92262) Fix Google Mail key error --- homeassistant/components/google_mail/sensor.py | 6 +++--- .../fixtures/get_vacation_no_dates.json | 6 ++++++ tests/components/google_mail/test_sensor.py | 17 ++++++++++++++--- 3 files changed, 23 insertions(+), 6 deletions(-) create mode 100644 tests/components/google_mail/fixtures/get_vacation_no_dates.json diff --git a/homeassistant/components/google_mail/sensor.py b/homeassistant/components/google_mail/sensor.py index c30ea1c0a65a..8023b9222a04 100644 --- a/homeassistant/components/google_mail/sensor.py +++ b/homeassistant/components/google_mail/sensor.py @@ -43,10 +43,10 @@ class GoogleMailSensor(GoogleMailEntity, SensorEntity): """Get the vacation data.""" service = await self.auth.get_resource() settings: HttpRequest = service.users().settings().getVacation(userId="me") - data = await self.hass.async_add_executor_job(settings.execute) + data: dict = await self.hass.async_add_executor_job(settings.execute) - if data["enableAutoReply"]: - value = datetime.fromtimestamp(int(data["endTime"]) / 1000, tz=timezone.utc) + if data["enableAutoReply"] and (end := data.get("endTime")): + value = datetime.fromtimestamp(int(end) / 1000, tz=timezone.utc) else: value = None self._attr_native_value = value diff --git a/tests/components/google_mail/fixtures/get_vacation_no_dates.json b/tests/components/google_mail/fixtures/get_vacation_no_dates.json new file mode 100644 index 000000000000..05abae4c7050 --- /dev/null +++ b/tests/components/google_mail/fixtures/get_vacation_no_dates.json @@ -0,0 +1,6 @@ +{ + "enableAutoReply": true, + "responseSubject": "Vacation", + "responseBodyPlainText": "I am on vacation.", + "restrictToContacts": false +} diff --git a/tests/components/google_mail/test_sensor.py b/tests/components/google_mail/test_sensor.py index 369557ad3e93..248622d3157e 100644 --- a/tests/components/google_mail/test_sensor.py +++ b/tests/components/google_mail/test_sensor.py @@ -4,6 +4,7 @@ from unittest.mock import patch from google.auth.exceptions import RefreshError from httplib2 import Response +import pytest from homeassistant import config_entries from homeassistant.components.google_mail.const import DOMAIN @@ -17,7 +18,17 @@ from .conftest import SENSOR, TOKEN, ComponentSetup from tests.common import async_fire_time_changed, load_fixture -async def test_sensors(hass: HomeAssistant, setup_integration: ComponentSetup) -> None: +@pytest.mark.parametrize( + ("fixture", "result"), + [ + ("get_vacation", "2022-11-18T05:00:00+00:00"), + ("get_vacation_no_dates", STATE_UNKNOWN), + ("get_vacation_off", STATE_UNKNOWN), + ], +) +async def test_sensors( + hass: HomeAssistant, setup_integration: ComponentSetup, fixture: str, result: str +) -> None: """Test we get sensor data.""" await setup_integration() @@ -29,7 +40,7 @@ async def test_sensors(hass: HomeAssistant, setup_integration: ComponentSetup) - "httplib2.Http.request", return_value=( Response({}), - bytes(load_fixture("google_mail/get_vacation_off.json"), encoding="UTF-8"), + bytes(load_fixture(f"google_mail/{fixture}.json"), encoding="UTF-8"), ), ): next_update = dt_util.utcnow() + timedelta(minutes=15) @@ -37,7 +48,7 @@ async def test_sensors(hass: HomeAssistant, setup_integration: ComponentSetup) - await hass.async_block_till_done() state = hass.states.get(SENSOR) - assert state.state == STATE_UNKNOWN + assert state.state == result async def test_sensor_reauth_trigger( From ed3c1d9b5933d3f39a3e14720b58398720c5027a Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 30 Apr 2023 02:21:38 +0200 Subject: [PATCH 090/822] Fix incorrect entity category for huawei_lte sensors (#92235) --- homeassistant/components/huawei_lte/sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/huawei_lte/sensor.py b/homeassistant/components/huawei_lte/sensor.py index 705269292771..f63cc4aac397 100644 --- a/homeassistant/components/huawei_lte/sensor.py +++ b/homeassistant/components/huawei_lte/sensor.py @@ -574,7 +574,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { {"0": "Auto", "1": "Manual"}.get(x), None, ), - entity_category=EntityCategory.CONFIG, + entity_category=EntityCategory.DIAGNOSTIC, ), }, ), @@ -596,7 +596,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { }.get(x), None, ), - entity_category=EntityCategory.CONFIG, + entity_category=EntityCategory.DIAGNOSTIC, ), }, ), From 3363830ede0bba1d7aa5aaf401d79b2f0a64cb05 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 30 Apr 2023 02:21:55 +0200 Subject: [PATCH 091/822] Fix incorrect entity category for SmartThings sensors (#92232) --- .../components/smartthings/sensor.py | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 4f5d93969e89..40e236f91850 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -45,7 +45,7 @@ CAPABILITY_TO_SENSORS: dict[str, list[Map]] = { None, None, None, - EntityCategory.CONFIG, + EntityCategory.DIAGNOSTIC, ) ], Capability.air_conditioner_mode: [ @@ -55,7 +55,7 @@ CAPABILITY_TO_SENSORS: dict[str, list[Map]] = { None, None, None, - EntityCategory.CONFIG, + EntityCategory.DIAGNOSTIC, ) ], Capability.air_quality_sensor: [ @@ -160,7 +160,7 @@ CAPABILITY_TO_SENSORS: dict[str, list[Map]] = { None, None, None, - EntityCategory.CONFIG, + EntityCategory.DIAGNOSTIC, ) ], Capability.dryer_operating_state: [ @@ -303,7 +303,7 @@ CAPABILITY_TO_SENSORS: dict[str, list[Map]] = { None, None, None, - EntityCategory.CONFIG, + EntityCategory.DIAGNOSTIC, ) ], Capability.oven_operating_state: [ @@ -362,7 +362,7 @@ CAPABILITY_TO_SENSORS: dict[str, list[Map]] = { None, None, None, - EntityCategory.CONFIG, + EntityCategory.DIAGNOSTIC, ) ], Capability.robot_cleaner_movement: [ @@ -382,7 +382,7 @@ CAPABILITY_TO_SENSORS: dict[str, list[Map]] = { None, None, None, - EntityCategory.CONFIG, + EntityCategory.DIAGNOSTIC, ) ], Capability.signal_strength: [ @@ -433,7 +433,7 @@ CAPABILITY_TO_SENSORS: dict[str, list[Map]] = { None, None, None, - EntityCategory.CONFIG, + EntityCategory.DIAGNOSTIC, ) ], Capability.thermostat_heating_setpoint: [ @@ -443,7 +443,7 @@ CAPABILITY_TO_SENSORS: dict[str, list[Map]] = { None, SensorDeviceClass.TEMPERATURE, None, - EntityCategory.CONFIG, + EntityCategory.DIAGNOSTIC, ) ], Capability.thermostat_mode: [ @@ -453,7 +453,7 @@ CAPABILITY_TO_SENSORS: dict[str, list[Map]] = { None, None, None, - EntityCategory.CONFIG, + EntityCategory.DIAGNOSTIC, ) ], Capability.thermostat_operating_state: [ @@ -473,7 +473,7 @@ CAPABILITY_TO_SENSORS: dict[str, list[Map]] = { None, SensorDeviceClass.TEMPERATURE, None, - EntityCategory.CONFIG, + EntityCategory.DIAGNOSTIC, ) ], Capability.three_axis: [], @@ -518,7 +518,7 @@ CAPABILITY_TO_SENSORS: dict[str, list[Map]] = { None, None, None, - EntityCategory.CONFIG, + EntityCategory.DIAGNOSTIC, ) ], Capability.washer_operating_state: [ From 7d518c5484b42c3595a030bc8321bfbcaf2b2b5e Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 30 Apr 2023 02:22:15 +0200 Subject: [PATCH 092/822] Update coverage to 7.2.4 (#92229) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 73a8e00df7da..1ed6c5c5215b 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.3 -coverage==7.2.3 +coverage==7.2.4 freezegun==1.2.2 mock-open==1.4.0 mypy==1.2.0 From 3a5a9a90b284d98da86f25cab4a0f884553796ae Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 30 Apr 2023 02:22:33 +0200 Subject: [PATCH 093/822] Update orjson to 3.8.11 (#92228) --- 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 7b235127e92b..72bf7fcd22db 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ janus==1.0.0 jinja2==3.1.2 lru-dict==1.1.8 mutagen==1.46.0 -orjson==3.8.10 +orjson==3.8.11 paho-mqtt==1.6.1 pillow==9.5.0 pip>=21.0,<23.2 diff --git a/pyproject.toml b/pyproject.toml index 51fa26025b6a..b2663ebe886b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,7 @@ dependencies = [ "cryptography==40.0.2", # pyOpenSSL 23.1.0 is required to work with cryptography 39+ "pyOpenSSL==23.1.0", - "orjson==3.8.10", + "orjson==3.8.11", "pip>=21.0,<23.2", "python-slugify==4.0.1", "pyyaml==6.0", diff --git a/requirements.txt b/requirements.txt index 72739ccd40c5..d59a4f976521 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,7 +18,7 @@ lru-dict==1.1.8 PyJWT==2.6.0 cryptography==40.0.2 pyOpenSSL==23.1.0 -orjson==3.8.10 +orjson==3.8.11 pip>=21.0,<23.2 python-slugify==4.0.1 pyyaml==6.0 From 1a82b353e0d102eea6ccd57fae2bc153d2004f77 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 29 Apr 2023 20:17:09 -0500 Subject: [PATCH 094/822] Auto repair incorrect collation on MySQL schema (#92270) * Auto repair incorrect collation on MySQL schema As we do more union queries in 2023.5.x if there is a mismatch between collations on tables, they will fail with an error that is hard for the user to figure out how to fix `Error executing query: (MySQLdb.OperationalError) (1271, "Illegal mix of collations for operation UNION")` This was reported in the #beta channel and by PM from others so the problem is not isolated to a single user https://discord.com/channels/330944238910963714/427516175237382144/1100908739910963272 * test with ascii since older maraidb versions may not work otherwise * Revert "test with ascii since older maraidb versions may not work otherwise" This reverts commit 787fda1aefcd8418a28a8a8f430e7e7232218ef8.t * older version need to check collation_server because the collation is not reflected if its the default --- .../recorder/auto_repairs/events/schema.py | 9 ++- .../recorder/auto_repairs/schema.py | 60 ++++++++++++++++- .../recorder/auto_repairs/states/schema.py | 3 + .../auto_repairs/statistics/schema.py | 3 + .../auto_repairs/events/test_schema.py | 29 +++++++++ .../auto_repairs/states/test_schema.py | 29 +++++++++ .../auto_repairs/statistics/test_schema.py | 30 +++++++++ .../recorder/auto_repairs/test_schema.py | 64 +++++++++++++++++++ 8 files changed, 224 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/recorder/auto_repairs/events/schema.py b/homeassistant/components/recorder/auto_repairs/events/schema.py index e32cbd4df7f9..3cc2e74f95b6 100644 --- a/homeassistant/components/recorder/auto_repairs/events/schema.py +++ b/homeassistant/components/recorder/auto_repairs/events/schema.py @@ -8,6 +8,7 @@ from ..schema import ( correct_db_schema_precision, correct_db_schema_utf8, validate_db_schema_precision, + validate_table_schema_has_correct_collation, validate_table_schema_supports_utf8, ) @@ -17,9 +18,12 @@ if TYPE_CHECKING: def validate_db_schema(instance: Recorder) -> set[str]: """Do some basic checks for common schema errors caused by manual migration.""" - return validate_table_schema_supports_utf8( + schema_errors = validate_table_schema_supports_utf8( instance, EventData, (EventData.shared_data,) ) | validate_db_schema_precision(instance, Events) + for table in (Events, EventData): + schema_errors |= validate_table_schema_has_correct_collation(instance, table) + return schema_errors def correct_db_schema( @@ -27,5 +31,6 @@ def correct_db_schema( schema_errors: set[str], ) -> None: """Correct issues detected by validate_db_schema.""" - correct_db_schema_utf8(instance, EventData, schema_errors) + for table in (Events, EventData): + correct_db_schema_utf8(instance, table, schema_errors) correct_db_schema_precision(instance, Events, schema_errors) diff --git a/homeassistant/components/recorder/auto_repairs/schema.py b/homeassistant/components/recorder/auto_repairs/schema.py index ec05eafd1406..aa036f33999b 100644 --- a/homeassistant/components/recorder/auto_repairs/schema.py +++ b/homeassistant/components/recorder/auto_repairs/schema.py @@ -5,6 +5,7 @@ from collections.abc import Iterable, Mapping import logging from typing import TYPE_CHECKING +from sqlalchemy import MetaData from sqlalchemy.exc import OperationalError from sqlalchemy.orm import DeclarativeBase from sqlalchemy.orm.attributes import InstrumentedAttribute @@ -60,6 +61,60 @@ def validate_table_schema_supports_utf8( return schema_errors +def validate_table_schema_has_correct_collation( + instance: Recorder, + table_object: type[DeclarativeBase], +) -> set[str]: + """Verify the table has the correct collation.""" + schema_errors: set[str] = set() + # Lack of full utf8 support is only an issue for MySQL / MariaDB + if instance.dialect_name != SupportedDialect.MYSQL: + return schema_errors + + try: + schema_errors = _validate_table_schema_has_correct_collation( + instance, table_object + ) + except Exception as exc: # pylint: disable=broad-except + _LOGGER.exception("Error when validating DB schema: %s", exc) + + _log_schema_errors(table_object, schema_errors) + return schema_errors + + +def _validate_table_schema_has_correct_collation( + instance: Recorder, + table_object: type[DeclarativeBase], +) -> set[str]: + """Ensure the table has the correct collation to avoid union errors with mixed collations.""" + schema_errors: set[str] = set() + # Mark the session as read_only to ensure that the test data is not committed + # to the database and we always rollback when the scope is exited + with session_scope(session=instance.get_session(), read_only=True) as session: + table = table_object.__tablename__ + metadata_obj = MetaData() + connection = session.connection() + metadata_obj.reflect(bind=connection) + dialect_kwargs = metadata_obj.tables[table].dialect_kwargs + # Check if the table has a collation set, if its not set than its + # using the server default collation for the database + + collate = ( + dialect_kwargs.get("mysql_collate") + or dialect_kwargs.get( + "mariadb_collate" + ) # pylint: disable-next=protected-access + or connection.dialect._fetch_setting(connection, "collation_server") # type: ignore[attr-defined] + ) + if collate and collate != "utf8mb4_unicode_ci": + _LOGGER.debug( + "Database %s collation is not utf8mb4_unicode_ci", + table, + ) + schema_errors.add(f"{table}.utf8mb4_unicode_ci") + return schema_errors + + def _validate_table_schema_supports_utf8( instance: Recorder, table_object: type[DeclarativeBase], @@ -184,7 +239,10 @@ def correct_db_schema_utf8( ) -> None: """Correct utf8 issues detected by validate_db_schema.""" table_name = table_object.__tablename__ - if f"{table_name}.4-byte UTF-8" in schema_errors: + if ( + f"{table_name}.4-byte UTF-8" in schema_errors + or f"{table_name}.utf8mb4_unicode_ci" in schema_errors + ): from ..migration import ( # pylint: disable=import-outside-toplevel _correct_table_character_set_and_collation, ) diff --git a/homeassistant/components/recorder/auto_repairs/states/schema.py b/homeassistant/components/recorder/auto_repairs/states/schema.py index 258e15cbb521..3c0daef452d8 100644 --- a/homeassistant/components/recorder/auto_repairs/states/schema.py +++ b/homeassistant/components/recorder/auto_repairs/states/schema.py @@ -8,6 +8,7 @@ from ..schema import ( correct_db_schema_precision, correct_db_schema_utf8, validate_db_schema_precision, + validate_table_schema_has_correct_collation, validate_table_schema_supports_utf8, ) @@ -26,6 +27,8 @@ def validate_db_schema(instance: Recorder) -> set[str]: for table, columns in TABLE_UTF8_COLUMNS.items(): schema_errors |= validate_table_schema_supports_utf8(instance, table, columns) schema_errors |= validate_db_schema_precision(instance, States) + for table in (States, StateAttributes): + schema_errors |= validate_table_schema_has_correct_collation(instance, table) return schema_errors diff --git a/homeassistant/components/recorder/auto_repairs/statistics/schema.py b/homeassistant/components/recorder/auto_repairs/statistics/schema.py index 9b4687cb72d7..607935bd6ffa 100644 --- a/homeassistant/components/recorder/auto_repairs/statistics/schema.py +++ b/homeassistant/components/recorder/auto_repairs/statistics/schema.py @@ -9,6 +9,7 @@ from ..schema import ( correct_db_schema_precision, correct_db_schema_utf8, validate_db_schema_precision, + validate_table_schema_has_correct_collation, validate_table_schema_supports_utf8, ) @@ -26,6 +27,7 @@ def validate_db_schema(instance: Recorder) -> set[str]: ) for table in (Statistics, StatisticsShortTerm): schema_errors |= validate_db_schema_precision(instance, table) + schema_errors |= validate_table_schema_has_correct_collation(instance, table) if schema_errors: _LOGGER.debug( "Detected statistics schema errors: %s", ", ".join(sorted(schema_errors)) @@ -41,3 +43,4 @@ def correct_db_schema( correct_db_schema_utf8(instance, StatisticsMeta, schema_errors) for table in (Statistics, StatisticsShortTerm): correct_db_schema_precision(instance, table, schema_errors) + correct_db_schema_utf8(instance, table, schema_errors) diff --git a/tests/components/recorder/auto_repairs/events/test_schema.py b/tests/components/recorder/auto_repairs/events/test_schema.py index b19ff4ca5033..1fd5d769c7c2 100644 --- a/tests/components/recorder/auto_repairs/events/test_schema.py +++ b/tests/components/recorder/auto_repairs/events/test_schema.py @@ -74,3 +74,32 @@ async def test_validate_db_schema_fix_utf8_issue_event_data( "Updating character set and collation of table event_data to utf8mb4" in caplog.text ) + + +@pytest.mark.parametrize("enable_schema_validation", [True]) +async def test_validate_db_schema_fix_collation_issue( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test validating DB schema with MySQL. + + Note: The test uses SQLite, the purpose is only to exercise the code. + """ + with patch( + "homeassistant.components.recorder.core.Recorder.dialect_name", "mysql" + ), patch( + "homeassistant.components.recorder.auto_repairs.schema._validate_table_schema_has_correct_collation", + return_value={"events.utf8mb4_unicode_ci"}, + ): + await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + + assert "Schema validation failed" not in caplog.text + assert ( + "Database is about to correct DB schema errors: events.utf8mb4_unicode_ci" + in caplog.text + ) + assert ( + "Updating character set and collation of table events to utf8mb4" in caplog.text + ) diff --git a/tests/components/recorder/auto_repairs/states/test_schema.py b/tests/components/recorder/auto_repairs/states/test_schema.py index 2e37001582e9..9b90489d7c01 100644 --- a/tests/components/recorder/auto_repairs/states/test_schema.py +++ b/tests/components/recorder/auto_repairs/states/test_schema.py @@ -104,3 +104,32 @@ async def test_validate_db_schema_fix_utf8_issue_state_attributes( "Updating character set and collation of table state_attributes to utf8mb4" in caplog.text ) + + +@pytest.mark.parametrize("enable_schema_validation", [True]) +async def test_validate_db_schema_fix_collation_issue( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test validating DB schema with MySQL. + + Note: The test uses SQLite, the purpose is only to exercise the code. + """ + with patch( + "homeassistant.components.recorder.core.Recorder.dialect_name", "mysql" + ), patch( + "homeassistant.components.recorder.auto_repairs.schema._validate_table_schema_has_correct_collation", + return_value={"states.utf8mb4_unicode_ci"}, + ): + await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + + assert "Schema validation failed" not in caplog.text + assert ( + "Database is about to correct DB schema errors: states.utf8mb4_unicode_ci" + in caplog.text + ) + assert ( + "Updating character set and collation of table states to utf8mb4" in caplog.text + ) diff --git a/tests/components/recorder/auto_repairs/statistics/test_schema.py b/tests/components/recorder/auto_repairs/statistics/test_schema.py index dfe036355aa7..10d1ed00b5bb 100644 --- a/tests/components/recorder/auto_repairs/statistics/test_schema.py +++ b/tests/components/recorder/auto_repairs/statistics/test_schema.py @@ -83,3 +83,33 @@ async def test_validate_db_schema_fix_float_issue( "sum DOUBLE PRECISION", ] modify_columns_mock.assert_called_once_with(ANY, ANY, table, modification) + + +@pytest.mark.parametrize("enable_schema_validation", [True]) +async def test_validate_db_schema_fix_collation_issue( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test validating DB schema with MySQL. + + Note: The test uses SQLite, the purpose is only to exercise the code. + """ + with patch( + "homeassistant.components.recorder.core.Recorder.dialect_name", "mysql" + ), patch( + "homeassistant.components.recorder.auto_repairs.schema._validate_table_schema_has_correct_collation", + return_value={"statistics.utf8mb4_unicode_ci"}, + ): + await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + + assert "Schema validation failed" not in caplog.text + assert ( + "Database is about to correct DB schema errors: statistics.utf8mb4_unicode_ci" + in caplog.text + ) + assert ( + "Updating character set and collation of table statistics to utf8mb4" + in caplog.text + ) diff --git a/tests/components/recorder/auto_repairs/test_schema.py b/tests/components/recorder/auto_repairs/test_schema.py index 510f46f98a21..ad2c33bfb881 100644 --- a/tests/components/recorder/auto_repairs/test_schema.py +++ b/tests/components/recorder/auto_repairs/test_schema.py @@ -10,6 +10,7 @@ from homeassistant.components.recorder.auto_repairs.schema import ( correct_db_schema_precision, correct_db_schema_utf8, validate_db_schema_precision, + validate_table_schema_has_correct_collation, validate_table_schema_supports_utf8, ) from homeassistant.components.recorder.db_schema import States @@ -106,6 +107,69 @@ async def test_validate_db_schema_fix_utf8_issue_with_broken_schema( assert schema_errors == set() +async def test_validate_db_schema_fix_incorrect_collation( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + recorder_db_url: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test validating DB schema with MySQL when the collation is incorrect.""" + if not recorder_db_url.startswith("mysql://"): + # This problem only happens on MySQL + return + await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + instance = get_instance(hass) + session_maker = instance.get_session + + def _break_states_schema(): + with session_scope(session=session_maker()) as session: + session.execute( + text( + "ALTER TABLE states CHARACTER SET utf8mb3 COLLATE utf8_general_ci, " + "LOCK=EXCLUSIVE;" + ) + ) + + await instance.async_add_executor_job(_break_states_schema) + schema_errors = await instance.async_add_executor_job( + validate_table_schema_has_correct_collation, instance, States + ) + assert schema_errors == {"states.utf8mb4_unicode_ci"} + + # Now repair the schema + await instance.async_add_executor_job( + correct_db_schema_utf8, instance, States, schema_errors + ) + + # Now validate the schema again + schema_errors = await instance.async_add_executor_job( + validate_table_schema_has_correct_collation, instance, States + ) + assert schema_errors == set() + + +async def test_validate_db_schema_precision_correct_collation( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + recorder_db_url: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test validating DB schema when the schema is correct with the correct collation.""" + if not recorder_db_url.startswith("mysql://"): + # This problem only happens on MySQL + return + await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + instance = get_instance(hass) + schema_errors = await instance.async_add_executor_job( + validate_table_schema_has_correct_collation, + instance, + States, + ) + assert schema_errors == set() + + async def test_validate_db_schema_fix_utf8_issue_with_broken_schema_unrepairable( async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant, From 193b2694a98cb8a8dfa2c414838e156bc79def5a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 29 Apr 2023 20:17:56 -0500 Subject: [PATCH 095/822] Handle AttributeError from wrong port in ONVIF config flow (#92272) * Handle AttributeError from wrong port in ONVIF config flow fixes ``` 2023-04-29 19:17:22.289 ERROR (MainThread) [aiohttp.server] Error handling request Traceback (most recent call last): File "/Users/bdraco/home-assistant/venv/lib/python3.10/site-packages/aiohttp/web_protocol.py", line 433, in _handle_request resp = await request_handler(request) File "/Users/bdraco/home-assistant/venv/lib/python3.10/site-packages/aiohttp/web_app.py", line 504, in _handle resp = await handler(request) File "/Users/bdraco/home-assistant/venv/lib/python3.10/site-packages/aiohttp/web_middlewares.py", line 117, in impl return await handler(request) File "/Users/bdraco/home-assistant/homeassistant/components/http/security_filter.py", line 85, in security_filter_middleware return await handler(request) File "/Users/bdraco/home-assistant/homeassistant/components/http/forwarded.py", line 100, in forwarded_middleware return await handler(request) File "/Users/bdraco/home-assistant/homeassistant/components/http/request_context.py", line 28, in request_context_middleware return await handler(request) File "/Users/bdraco/home-assistant/homeassistant/components/http/ban.py", line 80, in ban_middleware return await handler(request) File "/Users/bdraco/home-assistant/homeassistant/components/http/auth.py", line 235, in auth_middleware return await handler(request) File "/Users/bdraco/home-assistant/homeassistant/components/http/view.py", line 146, in handle result = await result File "/Users/bdraco/home-assistant/homeassistant/components/config/config_entries.py", line 180, in post return await super().post(request, flow_id) File "/Users/bdraco/home-assistant/homeassistant/components/http/data_validator.py", line 72, in wrapper result = await method(view, request, data, *args, **kwargs) File "/Users/bdraco/home-assistant/homeassistant/helpers/data_entry_flow.py", line 110, in post result = await self._flow_mgr.async_configure(flow_id, data) File "/Users/bdraco/home-assistant/homeassistant/data_entry_flow.py", line 271, in async_configure result = await self._async_handle_step( File "/Users/bdraco/home-assistant/homeassistant/data_entry_flow.py", line 367, in _async_handle_step result: FlowResult = await getattr(flow, method)(user_input) File "/Users/bdraco/home-assistant/homeassistant/components/onvif/config_flow.py", line 233, in async_step_configure errors, description_placeholders = await self.async_setup_profiles() File "/Users/bdraco/home-assistant/homeassistant/components/onvif/config_flow.py", line 277, in async_setup_profiles await device.update_xaddrs() File "/Users/bdraco/home-assistant/venv/lib/python3.10/site-packages/onvif/client.py", line 433, in update_xaddrs capabilities = await devicemgmt.GetCapabilities({"Category": "All"}) File "/Users/bdraco/home-assistant/venv/lib/python3.10/site-packages/zeep/proxy.py", line 64, in __call__ return await self._proxy._binding.send_async( File "/Users/bdraco/home-assistant/venv/lib/python3.10/site-packages/zeep/wsdl/bindings/soap.py", line 164, in send_async return self.process_reply(client, operation_obj, response) File "/Users/bdraco/home-assistant/venv/lib/python3.10/site-packages/zeep/wsdl/bindings/soap.py", line 204, in process_reply doc = parse_xml(content, self.transport, settings=client.settings) File "/Users/bdraco/home-assistant/venv/lib/python3.10/site-packages/zeep/loader.py", line 51, in parse_xml docinfo = elementtree.getroottree().docinfo AttributeError: NoneType object has no attribute getroottree ``` * port * Revert "port" This reverts commit 4693f3f33af18af66672dbd5ce6774f35ba28316. * misfire --- homeassistant/components/onvif/config_flow.py | 9 +++ homeassistant/components/onvif/strings.json | 1 + tests/components/onvif/__init__.py | 6 +- tests/components/onvif/test_config_flow.py | 79 +++++++++++++++++++ 4 files changed, 94 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/onvif/config_flow.py b/homeassistant/components/onvif/config_flow.py index 181b3321c538..68a4ce525119 100644 --- a/homeassistant/components/onvif/config_flow.py +++ b/homeassistant/components/onvif/config_flow.py @@ -316,6 +316,15 @@ class OnvifFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): # Verify there is an H264 profile media_service = device.create_media_service() profiles = await media_service.GetProfiles() + except AttributeError: # Likely an empty document or 404 from the wrong port + LOGGER.debug( + "%s: No ONVIF service found at %s:%s", + self.onvif_config[CONF_NAME], + self.onvif_config[CONF_HOST], + self.onvif_config[CONF_PORT], + exc_info=True, + ) + return {CONF_PORT: "no_onvif_service"}, {} except Fault as err: stringified_error = stringify_onvif_error(err) description_placeholders = {"error": stringified_error} diff --git a/homeassistant/components/onvif/strings.json b/homeassistant/components/onvif/strings.json index 07f2e6fb7ac5..55413e4bf6cd 100644 --- a/homeassistant/components/onvif/strings.json +++ b/homeassistant/components/onvif/strings.json @@ -11,6 +11,7 @@ "error": { "onvif_error": "Error setting up ONVIF device: {error}. Check logs for more information.", "auth_failed": "Could not authenticate: {error}", + "no_onvif_service": "No ONVIF service found. Check that the port number is correct.", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" }, "step": { diff --git a/tests/components/onvif/__init__.py b/tests/components/onvif/__init__.py index 1dfcc85cd284..18de9839e1bb 100644 --- a/tests/components/onvif/__init__.py +++ b/tests/components/onvif/__init__.py @@ -45,12 +45,14 @@ def setup_mock_onvif_camera( update_xaddrs_fail=False, no_profiles=False, auth_failure=False, + wrong_port=False, ): """Prepare mock onvif.ONVIFCamera.""" devicemgmt = MagicMock() device_info = MagicMock() device_info.SerialNumber = SERIAL_NUMBER if with_serial else None + devicemgmt.GetDeviceInformation = AsyncMock(return_value=device_info) interface = MagicMock() @@ -82,7 +84,9 @@ def setup_mock_onvif_camera( else: media_service.GetProfiles = AsyncMock(return_value=[profile1, profile2]) - if auth_failure: + if wrong_port: + mock_onvif_camera.update_xaddrs = AsyncMock(side_effect=AttributeError) + elif auth_failure: mock_onvif_camera.update_xaddrs = AsyncMock( side_effect=Fault( "not authorized", subcodes=[MagicMock(text="NotAuthorized")] diff --git a/tests/components/onvif/test_config_flow.py b/tests/components/onvif/test_config_flow.py index 805b5d85db8b..21ef1cf3fc24 100644 --- a/tests/components/onvif/test_config_flow.py +++ b/tests/components/onvif/test_config_flow.py @@ -829,3 +829,82 @@ async def test_flow_manual_entry_updates_existing_user_password( assert entry.data[config_flow.CONF_USERNAME] == USERNAME assert entry.data[config_flow.CONF_PASSWORD] == "new_password" assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_flow_manual_entry_wrong_port(hass: HomeAssistant) -> None: + """Test that we get a useful error with the wrong port.""" + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.onvif.config_flow.get_device" + ) as mock_onvif_camera, patch( + "homeassistant.components.onvif.config_flow.wsdiscovery" + ) as mock_discovery, patch( + "homeassistant.components.onvif.ONVIFDevice" + ) as mock_device: + setup_mock_onvif_camera(mock_onvif_camera, wrong_port=True) + # no discovery + mock_discovery.return_value = [] + setup_mock_device(mock_device) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"auto": False}, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "configure" + + with patch( + "homeassistant.components.onvif.async_setup_entry", return_value=True + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + config_flow.CONF_NAME: NAME, + config_flow.CONF_HOST: HOST, + config_flow.CONF_PORT: PORT, + config_flow.CONF_USERNAME: USERNAME, + config_flow.CONF_PASSWORD: PASSWORD, + }, + ) + + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 0 + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "configure" + assert result["errors"] == {"port": "no_onvif_service"} + assert result["description_placeholders"] == {} + setup_mock_onvif_camera(mock_onvif_camera, two_profiles=True) + + with patch( + "homeassistant.components.onvif.async_setup_entry", return_value=True + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + config_flow.CONF_NAME: NAME, + config_flow.CONF_HOST: HOST, + config_flow.CONF_PORT: PORT, + config_flow.CONF_USERNAME: USERNAME, + config_flow.CONF_PASSWORD: PASSWORD, + }, + ) + + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 + + assert result["title"] == f"{NAME} - {MAC}" + assert result["data"] == { + config_flow.CONF_NAME: NAME, + config_flow.CONF_HOST: HOST, + config_flow.CONF_PORT: PORT, + config_flow.CONF_USERNAME: USERNAME, + config_flow.CONF_PASSWORD: PASSWORD, + } From 2cb73b4a1f28891b0df7fe3dc9bdcdb2c6f44428 Mon Sep 17 00:00:00 2001 From: Tomer Figenblat Date: Sun, 30 Apr 2023 11:19:46 +0300 Subject: [PATCH 096/822] Remove myself from switcher_kis codeowners (#92277) chore: remove myself switcher_kis codeowners Signed-off-by: Tomer Figenblat --- CODEOWNERS | 4 ++-- homeassistant/components/switcher_kis/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 47b76523db80..b059808408bb 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1187,8 +1187,8 @@ build.json @home-assistant/supervisor /tests/components/switchbee/ @jafar-atili /homeassistant/components/switchbot/ @bdraco @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski /tests/components/switchbot/ @bdraco @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski -/homeassistant/components/switcher_kis/ @tomerfi @thecode -/tests/components/switcher_kis/ @tomerfi @thecode +/homeassistant/components/switcher_kis/ @thecode +/tests/components/switcher_kis/ @thecode /homeassistant/components/switchmate/ @danielhiversen @qiz-li /homeassistant/components/syncthing/ @zhulik /tests/components/syncthing/ @zhulik diff --git a/homeassistant/components/switcher_kis/manifest.json b/homeassistant/components/switcher_kis/manifest.json index 2be541c81061..823f2c5463f0 100644 --- a/homeassistant/components/switcher_kis/manifest.json +++ b/homeassistant/components/switcher_kis/manifest.json @@ -1,7 +1,7 @@ { "domain": "switcher_kis", "name": "Switcher", - "codeowners": ["@tomerfi", "@thecode"], + "codeowners": ["@thecode"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/switcher_kis/", "iot_class": "local_push", From 464c81ec9d6f9c26db6240bdb3e7d45aff7a1bd2 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Sun, 30 Apr 2023 09:15:57 -0400 Subject: [PATCH 097/822] Add missing fstrings in Local Calendar (#92288) --- homeassistant/components/local_calendar/calendar.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/local_calendar/calendar.py b/homeassistant/components/local_calendar/calendar.py index 423be8143b85..c8807d40cc13 100644 --- a/homeassistant/components/local_calendar/calendar.py +++ b/homeassistant/components/local_calendar/calendar.py @@ -129,7 +129,7 @@ class LocalCalendarEntity(CalendarEntity): recurrence_range=range_value, ) except EventStoreError as err: - raise HomeAssistantError("Error while deleting event: {err}") from err + raise HomeAssistantError(f"Error while deleting event: {err}") from err await self._async_store() await self.async_update_ha_state(force_refresh=True) @@ -153,7 +153,7 @@ class LocalCalendarEntity(CalendarEntity): recurrence_range=range_value, ) except EventStoreError as err: - raise HomeAssistantError("Error while updating event: {err}") from err + raise HomeAssistantError(f"Error while updating event: {err}") from err await self._async_store() await self.async_update_ha_state(force_refresh=True) From b629bf37bba32af4911236d48c9947b8501f1b0b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 30 Apr 2023 10:07:00 -0500 Subject: [PATCH 098/822] Bump bleak to 0.20.2 (#92294) --- 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 512d8d4ab999..c595a0a2cb9d 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -15,7 +15,7 @@ ], "quality_scale": "internal", "requirements": [ - "bleak==0.20.1", + "bleak==0.20.2", "bleak-retry-connector==3.0.2", "bluetooth-adapters==0.15.3", "bluetooth-auto-recovery==1.0.3", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 72bf7fcd22db..78121963cf51 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -12,7 +12,7 @@ attrs==22.2.0 awesomeversion==22.9.0 bcrypt==4.0.1 bleak-retry-connector==3.0.2 -bleak==0.20.1 +bleak==0.20.2 bluetooth-adapters==0.15.3 bluetooth-auto-recovery==1.0.3 bluetooth-data-tools==0.4.0 diff --git a/requirements_all.txt b/requirements_all.txt index e1dcdca833e5..b0de1f1235af 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -440,7 +440,7 @@ bizkaibus==0.1.1 bleak-retry-connector==3.0.2 # homeassistant.components.bluetooth -bleak==0.20.1 +bleak==0.20.2 # homeassistant.components.blebox blebox_uniapi==2.1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2805a7c73308..607b535dfb7e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -370,7 +370,7 @@ bimmer_connected==0.13.2 bleak-retry-connector==3.0.2 # homeassistant.components.bluetooth -bleak==0.20.1 +bleak==0.20.2 # homeassistant.components.blebox blebox_uniapi==2.1.4 From 2cc31d6a5bbb4c869fedf1f56d53d18c32dee30a Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 30 Apr 2023 17:07:55 +0200 Subject: [PATCH 099/822] Extract Rituals Perfume Genie DataUpdateCoordinator into module (#92284) --- .../rituals_perfume_genie/__init__.py | 29 ++----------------- .../rituals_perfume_genie/binary_sensor.py | 2 +- .../components/rituals_perfume_genie/const.py | 5 ++++ .../rituals_perfume_genie/coordinator.py | 29 +++++++++++++++++++ .../rituals_perfume_genie/entity.py | 2 +- .../rituals_perfume_genie/number.py | 2 +- .../rituals_perfume_genie/select.py | 2 +- .../rituals_perfume_genie/sensor.py | 2 +- .../rituals_perfume_genie/switch.py | 2 +- 9 files changed, 42 insertions(+), 33 deletions(-) create mode 100644 homeassistant/components/rituals_perfume_genie/coordinator.py diff --git a/homeassistant/components/rituals_perfume_genie/__init__.py b/homeassistant/components/rituals_perfume_genie/__init__.py index 18fd30754e24..7f079c003fc6 100644 --- a/homeassistant/components/rituals_perfume_genie/__init__.py +++ b/homeassistant/components/rituals_perfume_genie/__init__.py @@ -1,18 +1,15 @@ """The Rituals Perfume Genie integration.""" -from datetime import timedelta -import logging - import aiohttp -from pyrituals import Account, Diffuser +from pyrituals import Account 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 homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import ACCOUNT_HASH, COORDINATORS, DEVICES, DOMAIN +from .coordinator import RitualsDataUpdateCoordinator PLATFORMS = [ Platform.BINARY_SENSOR, @@ -22,10 +19,6 @@ PLATFORMS = [ Platform.SWITCH, ] -_LOGGER = logging.getLogger(__name__) - -UPDATE_INTERVAL = timedelta(minutes=2) - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Rituals Perfume Genie from a config entry.""" @@ -63,21 +56,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -class RitualsDataUpdateCoordinator(DataUpdateCoordinator[None]): - """Class to manage fetching Rituals Perfume Genie device data from single endpoint.""" - - def __init__(self, hass: HomeAssistant, device: Diffuser) -> None: - """Initialize global Rituals Perfume Genie data updater.""" - self._device = device - super().__init__( - hass, - _LOGGER, - name=f"{DOMAIN}-{device.hublot}", - update_interval=UPDATE_INTERVAL, - ) - - async def _async_update_data(self) -> None: - """Fetch data from Rituals.""" - await self._device.update_data() diff --git a/homeassistant/components/rituals_perfume_genie/binary_sensor.py b/homeassistant/components/rituals_perfume_genie/binary_sensor.py index 5e81ecb5cae4..05eac9377ab8 100644 --- a/homeassistant/components/rituals_perfume_genie/binary_sensor.py +++ b/homeassistant/components/rituals_perfume_genie/binary_sensor.py @@ -12,8 +12,8 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import RitualsDataUpdateCoordinator from .const import COORDINATORS, DEVICES, DOMAIN +from .coordinator import RitualsDataUpdateCoordinator from .entity import DiffuserEntity CHARGING_SUFFIX = " Battery Charging" diff --git a/homeassistant/components/rituals_perfume_genie/const.py b/homeassistant/components/rituals_perfume_genie/const.py index 21c570ffb935..05c034196201 100644 --- a/homeassistant/components/rituals_perfume_genie/const.py +++ b/homeassistant/components/rituals_perfume_genie/const.py @@ -1,7 +1,12 @@ """Constants for the Rituals Perfume Genie integration.""" + +from datetime import timedelta + DOMAIN = "rituals_perfume_genie" ACCOUNT_HASH = "account_hash" COORDINATORS = "coordinators" DEVICES = "devices" + +UPDATE_INTERVAL = timedelta(minutes=2) diff --git a/homeassistant/components/rituals_perfume_genie/coordinator.py b/homeassistant/components/rituals_perfume_genie/coordinator.py new file mode 100644 index 000000000000..ac6c252f4d27 --- /dev/null +++ b/homeassistant/components/rituals_perfume_genie/coordinator.py @@ -0,0 +1,29 @@ +"""The Rituals Perfume Genie data update coordinator.""" +import logging + +from pyrituals import Diffuser + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN, UPDATE_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +class RitualsDataUpdateCoordinator(DataUpdateCoordinator[None]): + """Class to manage fetching Rituals Perfume Genie device data from single endpoint.""" + + def __init__(self, hass: HomeAssistant, device: Diffuser) -> None: + """Initialize global Rituals Perfume Genie data updater.""" + self._device = device + super().__init__( + hass, + _LOGGER, + name=f"{DOMAIN}-{device.hublot}", + update_interval=UPDATE_INTERVAL, + ) + + async def _async_update_data(self) -> None: + """Fetch data from Rituals.""" + await self._device.update_data() diff --git a/homeassistant/components/rituals_perfume_genie/entity.py b/homeassistant/components/rituals_perfume_genie/entity.py index e3bf1ef4e634..fb049842c07a 100644 --- a/homeassistant/components/rituals_perfume_genie/entity.py +++ b/homeassistant/components/rituals_perfume_genie/entity.py @@ -6,8 +6,8 @@ from pyrituals import Diffuser from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import RitualsDataUpdateCoordinator from .const import DOMAIN +from .coordinator import RitualsDataUpdateCoordinator MANUFACTURER = "Rituals Cosmetics" MODEL = "The Perfume Genie" diff --git a/homeassistant/components/rituals_perfume_genie/number.py b/homeassistant/components/rituals_perfume_genie/number.py index 8fe0a663e379..232621ff5765 100644 --- a/homeassistant/components/rituals_perfume_genie/number.py +++ b/homeassistant/components/rituals_perfume_genie/number.py @@ -8,8 +8,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import RitualsDataUpdateCoordinator from .const import COORDINATORS, DEVICES, DOMAIN +from .coordinator import RitualsDataUpdateCoordinator from .entity import DiffuserEntity MIN_PERFUME_AMOUNT = 1 diff --git a/homeassistant/components/rituals_perfume_genie/select.py b/homeassistant/components/rituals_perfume_genie/select.py index 918ec7022c42..b7119e6d9473 100644 --- a/homeassistant/components/rituals_perfume_genie/select.py +++ b/homeassistant/components/rituals_perfume_genie/select.py @@ -9,8 +9,8 @@ from homeassistant.const import AREA_SQUARE_METERS, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import RitualsDataUpdateCoordinator from .const import COORDINATORS, DEVICES, DOMAIN +from .coordinator import RitualsDataUpdateCoordinator from .entity import DiffuserEntity ROOM_SIZE_SUFFIX = " Room Size" diff --git a/homeassistant/components/rituals_perfume_genie/sensor.py b/homeassistant/components/rituals_perfume_genie/sensor.py index 04102e8abccf..fa6f75b288f0 100644 --- a/homeassistant/components/rituals_perfume_genie/sensor.py +++ b/homeassistant/components/rituals_perfume_genie/sensor.py @@ -9,8 +9,8 @@ from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import RitualsDataUpdateCoordinator from .const import COORDINATORS, DEVICES, DOMAIN +from .coordinator import RitualsDataUpdateCoordinator from .entity import DiffuserEntity BATTERY_SUFFIX = " Battery" diff --git a/homeassistant/components/rituals_perfume_genie/switch.py b/homeassistant/components/rituals_perfume_genie/switch.py index a213db4e5db8..3c43a960a2d1 100644 --- a/homeassistant/components/rituals_perfume_genie/switch.py +++ b/homeassistant/components/rituals_perfume_genie/switch.py @@ -10,8 +10,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import RitualsDataUpdateCoordinator from .const import COORDINATORS, DEVICES, DOMAIN +from .coordinator import RitualsDataUpdateCoordinator from .entity import DiffuserEntity From 7a43b29f38a63a3bba96436d8b3fd73348b90796 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 30 Apr 2023 17:35:24 +0200 Subject: [PATCH 100/822] Fix mqtt not available when starting snips (#92296) --- homeassistant/components/snips/__init__.py | 5 +++++ tests/components/snips/test_init.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/snips/__init__.py b/homeassistant/components/snips/__init__.py index 01471b13bc70..3d19de74f91f 100644 --- a/homeassistant/components/snips/__init__.py +++ b/homeassistant/components/snips/__init__.py @@ -91,6 +91,11 @@ SERVICE_SCHEMA_FEEDBACK = vol.Schema( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Activate Snips component.""" + # Make sure MQTT integration is enabled and the client is available + if not await mqtt.async_wait_for_mqtt_client(hass): + _LOGGER.error("MQTT integration is not available") + return False + async def async_set_feedback(site_ids, state): """Set Feedback sound state.""" site_ids = site_ids if site_ids else config[DOMAIN].get(CONF_SITE_IDS) diff --git a/tests/components/snips/test_init.py b/tests/components/snips/test_init.py index 48e6b4421d5e..da2f23bc49c7 100644 --- a/tests/components/snips/test_init.py +++ b/tests/components/snips/test_init.py @@ -46,7 +46,7 @@ async def test_snips_no_mqtt( }, ) assert not result - assert "MQTT is not enabled" in caplog.text + assert "MQTT integration is not available" in caplog.text async def test_snips_bad_config( From 696e71419040e432b571ff6ee292a7e9f35c5b2e Mon Sep 17 00:00:00 2001 From: Maximilian <43999966+DeerMaximum@users.noreply.github.com> Date: Sun, 30 Apr 2023 16:05:22 +0000 Subject: [PATCH 101/822] Bump pynina to 0.3.0 (#92286) --- homeassistant/components/nina/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nina/manifest.json b/homeassistant/components/nina/manifest.json index be14b57ed473..6386a70d08b8 100644 --- a/homeassistant/components/nina/manifest.json +++ b/homeassistant/components/nina/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/nina", "iot_class": "cloud_polling", "loggers": ["pynina"], - "requirements": ["pynina==0.2.0"] + "requirements": ["pynina==0.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index b0de1f1235af..9646a683a3a7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1806,7 +1806,7 @@ pynetgear==0.10.9 pynetio==0.1.9.1 # homeassistant.components.nina -pynina==0.2.0 +pynina==0.3.0 # homeassistant.components.nobo_hub pynobo==1.6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 607b535dfb7e..5312ba76ba44 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1316,7 +1316,7 @@ pymysensors==0.24.0 pynetgear==0.10.9 # homeassistant.components.nina -pynina==0.2.0 +pynina==0.3.0 # homeassistant.components.nobo_hub pynobo==1.6.0 From a08cda3cfeedd0f90a010589b02d948603960cc7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 30 Apr 2023 11:06:09 -0500 Subject: [PATCH 102/822] Bump beacontools to fix conflict with construct<2.10 and >=2.8.16 (#92293) --- homeassistant/components/eddystone_temperature/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/eddystone_temperature/manifest.json b/homeassistant/components/eddystone_temperature/manifest.json index 075e8beb789b..dba5d35ab1a7 100644 --- a/homeassistant/components/eddystone_temperature/manifest.json +++ b/homeassistant/components/eddystone_temperature/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/eddystone_temperature", "iot_class": "local_polling", "loggers": ["beacontools"], - "requirements": ["beacontools[scan]==1.2.3", "construct==2.10.56"] + "requirements": ["beacontools[scan]==2.1.0", "construct==2.10.56"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9646a683a3a7..20d6c9ba7877 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -419,7 +419,7 @@ base36==0.1.1 batinfo==0.4.2 # homeassistant.components.eddystone_temperature -# beacontools[scan]==1.2.3 +# beacontools[scan]==2.1.0 # homeassistant.components.scrape beautifulsoup4==4.11.1 From 4fcf05c2025c6de7c39b4e20f01ab403d8c36aa1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 30 Apr 2023 11:06:38 -0500 Subject: [PATCH 103/822] Ensure onvif webhook can be registered (#92295) --- homeassistant/components/onvif/event.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/onvif/event.py b/homeassistant/components/onvif/event.py index 36e0bbbc66b0..a3b11fab1965 100644 --- a/homeassistant/components/onvif/event.py +++ b/homeassistant/components/onvif/event.py @@ -769,6 +769,7 @@ class WebHookManager: return webhook_id = self._webhook_unique_id + self._async_unregister_webhook() webhook.async_register( self._hass, DOMAIN, webhook_id, webhook_id, self._async_handle_webhook ) From ed286fc223b9d41b48ddd8d0523e2c8a92e7d126 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 30 Apr 2023 11:08:12 -0500 Subject: [PATCH 104/822] Fix august lock state when API reports locking and locked with the same timestamp (#92276) --- homeassistant/components/august/activity.py | 10 +++---- homeassistant/components/august/lock.py | 27 ++++++++++++------- homeassistant/components/august/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 26 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/august/activity.py b/homeassistant/components/august/activity.py index 687afdab4c71..ad9045a3d0d8 100644 --- a/homeassistant/components/august/activity.py +++ b/homeassistant/components/august/activity.py @@ -3,6 +3,7 @@ import asyncio import logging from aiohttp import ClientError +from yalexs.util import get_latest_activity from homeassistant.core import callback from homeassistant.helpers.debounce import Debouncer @@ -169,12 +170,11 @@ class ActivityStream(AugustSubscriberMixin): device_id = activity.device_id activity_type = activity.activity_type device_activities = self._latest_activities.setdefault(device_id, {}) - lastest_activity = device_activities.get(activity_type) - - # Ignore activities that are older than the latest one + # Ignore activities that are older than the latest one unless it is a non + # locking or unlocking activity with the exact same start time. if ( - lastest_activity - and lastest_activity.activity_start_time >= activity.activity_start_time + get_latest_activity(activity, device_activities.get(activity_type)) + != activity ): continue diff --git a/homeassistant/components/august/lock.py b/homeassistant/components/august/lock.py index d77a61a0659b..b11550dccd71 100644 --- a/homeassistant/components/august/lock.py +++ b/homeassistant/components/august/lock.py @@ -5,7 +5,7 @@ from typing import Any from aiohttp import ClientResponseError from yalexs.activity import SOURCE_PUBNUB, ActivityType from yalexs.lock import LockStatus -from yalexs.util import update_lock_detail_from_activity +from yalexs.util import get_latest_activity, update_lock_detail_from_activity from homeassistant.components.lock import ATTR_CHANGED_BY, LockEntity from homeassistant.config_entries import ConfigEntry @@ -90,17 +90,26 @@ class AugustLock(AugustEntityMixin, RestoreEntity, LockEntity): @callback def _update_from_data(self): """Get the latest state of the sensor and update activity.""" - lock_activity = self._data.activity_stream.get_latest_device_activity( - self._device_id, - {ActivityType.LOCK_OPERATION, ActivityType.LOCK_OPERATION_WITHOUT_OPERATOR}, + activity_stream = self._data.activity_stream + device_id = self._device_id + if lock_activity := activity_stream.get_latest_device_activity( + device_id, + {ActivityType.LOCK_OPERATION}, + ): + self._attr_changed_by = lock_activity.operated_by + + lock_activity_without_operator = activity_stream.get_latest_device_activity( + device_id, + {ActivityType.LOCK_OPERATION_WITHOUT_OPERATOR}, ) - if lock_activity is not None: - self._attr_changed_by = lock_activity.operated_by - update_lock_detail_from_activity(self._detail, lock_activity) - # If the source is pubnub the lock must be online since its a live update - if lock_activity.source == SOURCE_PUBNUB: + if latest_activity := get_latest_activity( + lock_activity_without_operator, lock_activity + ): + if latest_activity.source == SOURCE_PUBNUB: + # If the source is pubnub the lock must be online since its a live update self._detail.set_online(True) + update_lock_detail_from_activity(self._detail, latest_activity) bridge_activity = self._data.activity_stream.get_latest_device_activity( self._device_id, {ActivityType.BRIDGE_OPERATION} diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index 1233cf07c220..4e5f8354a4cb 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.3.2", "yalexs-ble==2.1.16"] + "requirements": ["yalexs==1.3.3", "yalexs-ble==2.1.16"] } diff --git a/requirements_all.txt b/requirements_all.txt index 20d6c9ba7877..ea12f37e0327 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2679,7 +2679,7 @@ yalesmartalarmclient==0.3.9 yalexs-ble==2.1.16 # homeassistant.components.august -yalexs==1.3.2 +yalexs==1.3.3 # homeassistant.components.yeelight yeelight==0.7.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5312ba76ba44..94a06d59d892 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1943,7 +1943,7 @@ yalesmartalarmclient==0.3.9 yalexs-ble==2.1.16 # homeassistant.components.august -yalexs==1.3.2 +yalexs==1.3.3 # homeassistant.components.yeelight yeelight==0.7.10 From cd52e050752e7d4854b2c3ed20deca2cb974bf6a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 30 Apr 2023 18:09:02 +0200 Subject: [PATCH 105/822] Move lastfm constants to separate file (#92289) --- homeassistant/components/lastfm/const.py | 15 +++++++++++++++ homeassistant/components/lastfm/sensor.py | 18 ++++++++---------- tests/components/lastfm/test_sensor.py | 2 +- 3 files changed, 24 insertions(+), 11 deletions(-) create mode 100644 homeassistant/components/lastfm/const.py diff --git a/homeassistant/components/lastfm/const.py b/homeassistant/components/lastfm/const.py new file mode 100644 index 000000000000..2a7f40b99e3a --- /dev/null +++ b/homeassistant/components/lastfm/const.py @@ -0,0 +1,15 @@ +"""Constants for LastFM.""" +import logging +from typing import Final + +LOGGER = logging.getLogger(__package__) +DOMAIN: Final = "lastfm" +DEFAULT_NAME = "LastFM" + +CONF_USERS = "users" + +ATTR_LAST_PLAYED = "last_played" +ATTR_PLAY_COUNT = "play_count" +ATTR_TOP_PLAYED = "top_played" + +STATE_NOT_SCROBBLING = "Not Scrobbling" diff --git a/homeassistant/components/lastfm/sensor.py b/homeassistant/components/lastfm/sensor.py index a25171f9c2ed..20c51f8a8c6a 100644 --- a/homeassistant/components/lastfm/sensor.py +++ b/homeassistant/components/lastfm/sensor.py @@ -2,7 +2,6 @@ from __future__ import annotations import hashlib -import logging from pylast import LastFMNetwork, Track, User, WSError import voluptuous as vol @@ -14,15 +13,14 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -LOGGER = logging.getLogger(__name__) - -CONF_USERS = "users" - -ATTR_LAST_PLAYED = "last_played" -ATTR_PLAY_COUNT = "play_count" -ATTR_TOP_PLAYED = "top_played" - -STATE_NOT_SCROBBLING = "Not Scrobbling" +from .const import ( + ATTR_LAST_PLAYED, + ATTR_PLAY_COUNT, + ATTR_TOP_PLAYED, + CONF_USERS, + LOGGER, + STATE_NOT_SCROBBLING, +) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { diff --git a/tests/components/lastfm/test_sensor.py b/tests/components/lastfm/test_sensor.py index 0fa45a122778..d9de78b8f8a4 100644 --- a/tests/components/lastfm/test_sensor.py +++ b/tests/components/lastfm/test_sensor.py @@ -5,7 +5,7 @@ from pylast import Track import pytest from homeassistant.components import sensor -from homeassistant.components.lastfm.sensor import STATE_NOT_SCROBBLING +from homeassistant.components.lastfm.const import STATE_NOT_SCROBBLING from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component From 9317ddb1c749ee273f603f5cdad021841c3421ec Mon Sep 17 00:00:00 2001 From: stickpin <630000+stickpin@users.noreply.github.com> Date: Sun, 30 Apr 2023 18:50:51 +0200 Subject: [PATCH 106/822] Home Connect add WasherDryer support (#90673) --- homeassistant/components/home_connect/api.py | 26 +++++++++++++++++++ .../components/home_connect/switch.py | 4 +++ 2 files changed, 30 insertions(+) diff --git a/homeassistant/components/home_connect/api.py b/homeassistant/components/home_connect/api.py index d0464968d4e8..10dc2d360fa2 100644 --- a/homeassistant/components/home_connect/api.py +++ b/homeassistant/components/home_connect/api.py @@ -74,6 +74,8 @@ class ConfigEntryAuth(homeconnect.HomeConnectAPI): device = Dryer(self.hass, app) elif app.type == "Washer": device = Washer(self.hass, app) + elif app.type == "WasherDryer": + device = WasherDryer(self.hass, app) elif app.type == "Dishwasher": device = Dishwasher(self.hass, app) elif app.type == "FridgeFreezer": @@ -358,6 +360,30 @@ class Washer( } +class WasherDryer( + DeviceWithDoor, + DeviceWithOpState, + DeviceWithPrograms, + DeviceWithRemoteControl, + DeviceWithRemoteStart, +): + """WasherDryer class.""" + + def get_entity_info(self): + """Get a dictionary with infos about the associated entities.""" + door_entity = self.get_door_entity() + remote_control = self.get_remote_control() + remote_start = self.get_remote_start() + op_state_sensor = self.get_opstate_sensor() + program_sensors = self.get_program_sensors() + program_switches = self.get_program_switches() + return { + "binary_sensor": [door_entity, remote_control, remote_start], + "switch": program_switches, + "sensor": program_sensors + op_state_sensor, + } + + class CoffeeMaker(DeviceWithOpState, DeviceWithPrograms, DeviceWithRemoteStart): """Coffee maker class.""" diff --git a/homeassistant/components/home_connect/switch.py b/homeassistant/components/home_connect/switch.py index 89b1f23589fd..61dd11dbc6f2 100644 --- a/homeassistant/components/home_connect/switch.py +++ b/homeassistant/components/home_connect/switch.py @@ -50,6 +50,10 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity): def __init__(self, device, program_name): """Initialize the entity.""" desc = " ".join(["Program", program_name.split(".")[-1]]) + if device.appliance.type == "WasherDryer": + desc = " ".join( + ["Program", program_name.split(".")[-3], program_name.split(".")[-1]] + ) super().__init__(device, desc) self.program_name = program_name self._state = None From b969790e95ab0ca93a6df40ead3692501aa968b4 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 30 Apr 2023 18:55:31 +0200 Subject: [PATCH 107/822] Use SnapshotAssertion in Renault tests (#90778) --- .../renault/snapshots/test_binary_sensor.ambr | 1163 ++++++++ .../renault/snapshots/test_button.ambr | 539 ++++ .../snapshots/test_device_tracker.ambr | 278 ++ .../renault/snapshots/test_select.ambr | 299 ++ .../renault/snapshots/test_sensor.ambr | 2400 +++++++++++++++++ .../components/renault/test_binary_sensor.py | 28 +- tests/components/renault/test_button.py | 21 +- .../components/renault/test_device_tracker.py | 28 +- tests/components/renault/test_select.py | 28 +- tests/components/renault/test_sensor.py | 62 +- 10 files changed, 4767 insertions(+), 79 deletions(-) create mode 100644 tests/components/renault/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/renault/snapshots/test_button.ambr create mode 100644 tests/components/renault/snapshots/test_device_tracker.ambr create mode 100644 tests/components/renault/snapshots/test_select.ambr create mode 100644 tests/components/renault/snapshots/test_sensor.ambr diff --git a/tests/components/renault/snapshots/test_binary_sensor.ambr b/tests/components/renault/snapshots/test_binary_sensor.ambr new file mode 100644 index 000000000000..ddeadd436cf9 --- /dev/null +++ b/tests/components/renault/snapshots/test_binary_sensor.ambr @@ -0,0 +1,1163 @@ +# serializer version: 1 +# name: test_binary_sensors[captur_fuel] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777123', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Captur ii', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'XJB1SU', + 'via_device_id': None, + }), + ]) +# --- +# name: test_binary_sensors[captur_fuel].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lock', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'lock_status', + 'unique_id': 'vf1aaaaa555777123_lock_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_hatch', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hatch', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'hatch_status', + 'unique_id': 'vf1aaaaa555777123_hatch_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_rear_left_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rear left door', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'rear_left_door_status', + 'unique_id': 'vf1aaaaa555777123_rear_left_door_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_rear_right_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rear right door', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'rear_right_door_status', + 'unique_id': 'vf1aaaaa555777123_rear_right_door_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_driver_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Driver door', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'driver_door_status', + 'unique_id': 'vf1aaaaa555777123_driver_door_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_passenger_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Passenger door', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'passenger_door_status', + 'unique_id': 'vf1aaaaa555777123_passenger_door_status', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_binary_sensors[captur_fuel].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'lock', + 'friendly_name': 'REG-NUMBER Lock', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_lock', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-NUMBER Hatch', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_hatch', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-NUMBER Rear left door', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_rear_left_door', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-NUMBER Rear right door', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_rear_right_door', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-NUMBER Driver door', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_driver_door', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-NUMBER Passenger door', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_passenger_door', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + ]) +# --- +# name: test_binary_sensors[captur_phev] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777123', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Captur ii', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'XJB1SU', + 'via_device_id': None, + }), + ]) +# --- +# name: test_binary_sensors[captur_phev].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_plug', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Plug', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'plugged_in', + 'unique_id': 'vf1aaaaa555777123_plugged_in', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_charging', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'charging', + 'unique_id': 'vf1aaaaa555777123_charging', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lock', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'lock_status', + 'unique_id': 'vf1aaaaa555777123_lock_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_hatch', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hatch', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'hatch_status', + 'unique_id': 'vf1aaaaa555777123_hatch_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_rear_left_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rear left door', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'rear_left_door_status', + 'unique_id': 'vf1aaaaa555777123_rear_left_door_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_rear_right_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rear right door', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'rear_right_door_status', + 'unique_id': 'vf1aaaaa555777123_rear_right_door_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_driver_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Driver door', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'driver_door_status', + 'unique_id': 'vf1aaaaa555777123_driver_door_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_passenger_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Passenger door', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'passenger_door_status', + 'unique_id': 'vf1aaaaa555777123_passenger_door_status', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_binary_sensors[captur_phev].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'plug', + 'friendly_name': 'REG-NUMBER Plug', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_plug', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery_charging', + 'friendly_name': 'REG-NUMBER Charging', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_charging', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'lock', + 'friendly_name': 'REG-NUMBER Lock', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_lock', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-NUMBER Hatch', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_hatch', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-NUMBER Rear left door', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_rear_left_door', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-NUMBER Rear right door', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_rear_right_door', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-NUMBER Driver door', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_driver_door', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-NUMBER Passenger door', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_passenger_door', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + ]) +# --- +# name: test_binary_sensors[zoe_40] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777999', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Zoe', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'X101VE', + 'via_device_id': None, + }), + ]) +# --- +# name: test_binary_sensors[zoe_40].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_plug', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Plug', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'plugged_in', + 'unique_id': 'vf1aaaaa555777999_plugged_in', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_charging', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'charging', + 'unique_id': 'vf1aaaaa555777999_charging', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_hvac', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:fan-off', + 'original_name': 'HVAC', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'hvac_status', + 'unique_id': 'vf1aaaaa555777999_hvac_status', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_binary_sensors[zoe_40].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'plug', + 'friendly_name': 'REG-NUMBER Plug', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_plug', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery_charging', + 'friendly_name': 'REG-NUMBER Charging', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_charging', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER HVAC', + 'icon': 'mdi:fan-off', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_hvac', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + ]) +# --- +# name: test_binary_sensors[zoe_50] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777999', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Zoe', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'X102VE', + 'via_device_id': None, + }), + ]) +# --- +# name: test_binary_sensors[zoe_50].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_plug', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Plug', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'plugged_in', + 'unique_id': 'vf1aaaaa555777999_plugged_in', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_charging', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'charging', + 'unique_id': 'vf1aaaaa555777999_charging', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_hvac', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:fan-off', + 'original_name': 'HVAC', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'hvac_status', + 'unique_id': 'vf1aaaaa555777999_hvac_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lock', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'lock_status', + 'unique_id': 'vf1aaaaa555777999_lock_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_hatch', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hatch', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'hatch_status', + 'unique_id': 'vf1aaaaa555777999_hatch_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_rear_left_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rear left door', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'rear_left_door_status', + 'unique_id': 'vf1aaaaa555777999_rear_left_door_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_rear_right_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rear right door', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'rear_right_door_status', + 'unique_id': 'vf1aaaaa555777999_rear_right_door_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_driver_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Driver door', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'driver_door_status', + 'unique_id': 'vf1aaaaa555777999_driver_door_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_passenger_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Passenger door', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'passenger_door_status', + 'unique_id': 'vf1aaaaa555777999_passenger_door_status', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_binary_sensors[zoe_50].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'plug', + 'friendly_name': 'REG-NUMBER Plug', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_plug', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery_charging', + 'friendly_name': 'REG-NUMBER Charging', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_charging', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER HVAC', + 'icon': 'mdi:fan-off', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_hvac', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'lock', + 'friendly_name': 'REG-NUMBER Lock', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_lock', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-NUMBER Hatch', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_hatch', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-NUMBER Rear left door', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_rear_left_door', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-NUMBER Rear right door', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_rear_right_door', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-NUMBER Driver door', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_driver_door', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-NUMBER Passenger door', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_passenger_door', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + ]) +# --- diff --git a/tests/components/renault/snapshots/test_button.ambr b/tests/components/renault/snapshots/test_button.ambr new file mode 100644 index 000000000000..8a0c8af7aa79 --- /dev/null +++ b/tests/components/renault/snapshots/test_button.ambr @@ -0,0 +1,539 @@ +# serializer version: 1 +# name: test_buttons[captur_fuel] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777123', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Captur ii', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'XJB1SU', + 'via_device_id': None, + }), + ]) +# --- +# name: test_buttons[captur_fuel].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_number_start_air_conditioner', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:air-conditioner', + 'original_name': 'Start air conditioner', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'start_air_conditioner', + 'unique_id': 'vf1aaaaa555777123_start_air_conditioner', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_buttons[captur_fuel].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Start air conditioner', + 'icon': 'mdi:air-conditioner', + }), + 'context': , + 'entity_id': 'button.reg_number_start_air_conditioner', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + ]) +# --- +# name: test_buttons[captur_phev] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777123', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Captur ii', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'XJB1SU', + 'via_device_id': None, + }), + ]) +# --- +# name: test_buttons[captur_phev].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_number_start_air_conditioner', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:air-conditioner', + 'original_name': 'Start air conditioner', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'start_air_conditioner', + 'unique_id': 'vf1aaaaa555777123_start_air_conditioner', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_number_start_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:ev-station', + 'original_name': 'Start charge', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'start_charge', + 'unique_id': 'vf1aaaaa555777123_start_charge', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_number_stop_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:ev-station', + 'original_name': 'Stop charge', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'stop_charge', + 'unique_id': 'vf1aaaaa555777123_stop_charge', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_buttons[captur_phev].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Start air conditioner', + 'icon': 'mdi:air-conditioner', + }), + 'context': , + 'entity_id': 'button.reg_number_start_air_conditioner', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Start charge', + 'icon': 'mdi:ev-station', + }), + 'context': , + 'entity_id': 'button.reg_number_start_charge', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Stop charge', + 'icon': 'mdi:ev-station', + }), + 'context': , + 'entity_id': 'button.reg_number_stop_charge', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + ]) +# --- +# name: test_buttons[zoe_40] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777999', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Zoe', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'X101VE', + 'via_device_id': None, + }), + ]) +# --- +# name: test_buttons[zoe_40].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_number_start_air_conditioner', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:air-conditioner', + 'original_name': 'Start air conditioner', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'start_air_conditioner', + 'unique_id': 'vf1aaaaa555777999_start_air_conditioner', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_number_start_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:ev-station', + 'original_name': 'Start charge', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'start_charge', + 'unique_id': 'vf1aaaaa555777999_start_charge', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_number_stop_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:ev-station', + 'original_name': 'Stop charge', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'stop_charge', + 'unique_id': 'vf1aaaaa555777999_stop_charge', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_buttons[zoe_40].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Start air conditioner', + 'icon': 'mdi:air-conditioner', + }), + 'context': , + 'entity_id': 'button.reg_number_start_air_conditioner', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Start charge', + 'icon': 'mdi:ev-station', + }), + 'context': , + 'entity_id': 'button.reg_number_start_charge', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Stop charge', + 'icon': 'mdi:ev-station', + }), + 'context': , + 'entity_id': 'button.reg_number_stop_charge', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + ]) +# --- +# name: test_buttons[zoe_50] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777999', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Zoe', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'X102VE', + 'via_device_id': None, + }), + ]) +# --- +# name: test_buttons[zoe_50].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_number_start_air_conditioner', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:air-conditioner', + 'original_name': 'Start air conditioner', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'start_air_conditioner', + 'unique_id': 'vf1aaaaa555777999_start_air_conditioner', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_number_start_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:ev-station', + 'original_name': 'Start charge', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'start_charge', + 'unique_id': 'vf1aaaaa555777999_start_charge', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_number_stop_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:ev-station', + 'original_name': 'Stop charge', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'stop_charge', + 'unique_id': 'vf1aaaaa555777999_stop_charge', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_buttons[zoe_50].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Start air conditioner', + 'icon': 'mdi:air-conditioner', + }), + 'context': , + 'entity_id': 'button.reg_number_start_air_conditioner', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Start charge', + 'icon': 'mdi:ev-station', + }), + 'context': , + 'entity_id': 'button.reg_number_start_charge', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Stop charge', + 'icon': 'mdi:ev-station', + }), + 'context': , + 'entity_id': 'button.reg_number_stop_charge', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + ]) +# --- diff --git a/tests/components/renault/snapshots/test_device_tracker.ambr b/tests/components/renault/snapshots/test_device_tracker.ambr new file mode 100644 index 000000000000..571ef2203e10 --- /dev/null +++ b/tests/components/renault/snapshots/test_device_tracker.ambr @@ -0,0 +1,278 @@ +# serializer version: 1 +# name: test_device_trackers[captur_fuel] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777123', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Captur ii', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'XJB1SU', + 'via_device_id': None, + }), + ]) +# --- +# name: test_device_trackers[captur_fuel].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'device_tracker', + 'entity_category': , + 'entity_id': 'device_tracker.reg_number_location', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:car', + 'original_name': 'Location', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'location', + 'unique_id': 'vf1aaaaa555777123_location', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_device_trackers[captur_fuel].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Location', + 'gps_accuracy': 0, + 'icon': 'mdi:car', + 'latitude': 48.1234567, + 'longitude': 11.1234567, + 'source_type': , + }), + 'context': , + 'entity_id': 'device_tracker.reg_number_location', + 'last_changed': , + 'last_updated': , + 'state': 'not_home', + }), + ]) +# --- +# name: test_device_trackers[captur_phev] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777123', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Captur ii', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'XJB1SU', + 'via_device_id': None, + }), + ]) +# --- +# name: test_device_trackers[captur_phev].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'device_tracker', + 'entity_category': , + 'entity_id': 'device_tracker.reg_number_location', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:car', + 'original_name': 'Location', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'location', + 'unique_id': 'vf1aaaaa555777123_location', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_device_trackers[captur_phev].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Location', + 'gps_accuracy': 0, + 'icon': 'mdi:car', + 'latitude': 48.1234567, + 'longitude': 11.1234567, + 'source_type': , + }), + 'context': , + 'entity_id': 'device_tracker.reg_number_location', + 'last_changed': , + 'last_updated': , + 'state': 'not_home', + }), + ]) +# --- +# name: test_device_trackers[zoe_40] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777999', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Zoe', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'X101VE', + 'via_device_id': None, + }), + ]) +# --- +# name: test_device_trackers[zoe_40].1 + list([ + ]) +# --- +# name: test_device_trackers[zoe_40].2 + list([ + ]) +# --- +# name: test_device_trackers[zoe_50] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777999', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Zoe', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'X102VE', + 'via_device_id': None, + }), + ]) +# --- +# name: test_device_trackers[zoe_50].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'device_tracker', + 'entity_category': , + 'entity_id': 'device_tracker.reg_number_location', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:car', + 'original_name': 'Location', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'location', + 'unique_id': 'vf1aaaaa555777999_location', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_device_trackers[zoe_50].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Location', + 'gps_accuracy': 0, + 'icon': 'mdi:car', + 'latitude': 48.1234567, + 'longitude': 11.1234567, + 'source_type': , + }), + 'context': , + 'entity_id': 'device_tracker.reg_number_location', + 'last_changed': , + 'last_updated': , + 'state': 'not_home', + }), + ]) +# --- diff --git a/tests/components/renault/snapshots/test_select.ambr b/tests/components/renault/snapshots/test_select.ambr new file mode 100644 index 000000000000..4c7103ebe5b6 --- /dev/null +++ b/tests/components/renault/snapshots/test_select.ambr @@ -0,0 +1,299 @@ +# serializer version: 1 +# name: test_selects[captur_fuel] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777123', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Captur ii', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'XJB1SU', + 'via_device_id': None, + }), + ]) +# --- +# name: test_selects[captur_fuel].1 + list([ + ]) +# --- +# name: test_selects[captur_fuel].2 + list([ + ]) +# --- +# name: test_selects[captur_phev] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777123', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Captur ii', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'XJB1SU', + 'via_device_id': None, + }), + ]) +# --- +# name: test_selects[captur_phev].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'always', + 'always_charging', + 'schedule_mode', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.reg_number_charge_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:calendar-remove', + 'original_name': 'Charge mode', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'charge_mode', + 'unique_id': 'vf1aaaaa555777123_charge_mode', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_selects[captur_phev].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Charge mode', + 'icon': 'mdi:calendar-remove', + 'options': list([ + 'always', + 'always_charging', + 'schedule_mode', + ]), + }), + 'context': , + 'entity_id': 'select.reg_number_charge_mode', + 'last_changed': , + 'last_updated': , + 'state': 'always', + }), + ]) +# --- +# name: test_selects[zoe_40] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777999', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Zoe', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'X101VE', + 'via_device_id': None, + }), + ]) +# --- +# name: test_selects[zoe_40].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'always', + 'always_charging', + 'schedule_mode', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.reg_number_charge_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:calendar-remove', + 'original_name': 'Charge mode', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'charge_mode', + 'unique_id': 'vf1aaaaa555777999_charge_mode', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_selects[zoe_40].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Charge mode', + 'icon': 'mdi:calendar-remove', + 'options': list([ + 'always', + 'always_charging', + 'schedule_mode', + ]), + }), + 'context': , + 'entity_id': 'select.reg_number_charge_mode', + 'last_changed': , + 'last_updated': , + 'state': 'always', + }), + ]) +# --- +# name: test_selects[zoe_50] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777999', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Zoe', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'X102VE', + 'via_device_id': None, + }), + ]) +# --- +# name: test_selects[zoe_50].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'always', + 'always_charging', + 'schedule_mode', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.reg_number_charge_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:calendar-clock', + 'original_name': 'Charge mode', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'charge_mode', + 'unique_id': 'vf1aaaaa555777999_charge_mode', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_selects[zoe_50].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Charge mode', + 'icon': 'mdi:calendar-clock', + 'options': list([ + 'always', + 'always_charging', + 'schedule_mode', + ]), + }), + 'context': , + 'entity_id': 'select.reg_number_charge_mode', + 'last_changed': , + 'last_updated': , + 'state': 'schedule_mode', + }), + ]) +# --- diff --git a/tests/components/renault/snapshots/test_sensor.ambr b/tests/components/renault/snapshots/test_sensor.ambr new file mode 100644 index 000000000000..2bf8c0c17bfe --- /dev/null +++ b/tests/components/renault/snapshots/test_sensor.ambr @@ -0,0 +1,2400 @@ +# serializer version: 1 +# name: test_sensors[captur_fuel] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777123', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Captur ii', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'XJB1SU', + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensors[captur_fuel].1 + list([ + 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.reg_number_mileage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:sign-direction', + 'original_name': 'Mileage', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'mileage', + 'unique_id': 'vf1aaaaa555777123_mileage', + 'unit_of_measurement': , + }), + 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.reg_number_fuel_autonomy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:gas-station', + 'original_name': 'Fuel autonomy', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'fuel_autonomy', + 'unique_id': 'vf1aaaaa555777123_fuel_autonomy', + 'unit_of_measurement': , + }), + 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.reg_number_fuel_quantity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:fuel', + 'original_name': 'Fuel quantity', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'fuel_quantity', + 'unique_id': 'vf1aaaaa555777123_fuel_quantity', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_last_location_activity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last location activity', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'location_last_activity', + 'unique_id': 'vf1aaaaa555777123_location_last_activity', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_remote_engine_start', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Remote engine start', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'res_state', + 'unique_id': 'vf1aaaaa555777123_res_state', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_remote_engine_start_code', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Remote engine start code', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'res_state_code', + 'unique_id': 'vf1aaaaa555777123_res_state_code', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_sensors[captur_fuel].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-NUMBER Mileage', + 'icon': 'mdi:sign-direction', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_mileage', + 'last_changed': , + 'last_updated': , + 'state': '5567', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-NUMBER Fuel autonomy', + 'icon': 'mdi:gas-station', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_fuel_autonomy', + 'last_changed': , + 'last_updated': , + 'state': '35', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume', + 'friendly_name': 'REG-NUMBER Fuel quantity', + 'icon': 'mdi:fuel', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_fuel_quantity', + 'last_changed': , + 'last_updated': , + 'state': '3', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'REG-NUMBER Last location activity', + }), + 'context': , + 'entity_id': 'sensor.reg_number_last_location_activity', + 'last_changed': , + 'last_updated': , + 'state': '2020-02-18T16:58:38+00:00', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Remote engine start', + }), + 'context': , + 'entity_id': 'sensor.reg_number_remote_engine_start', + 'last_changed': , + 'last_updated': , + 'state': 'Stopped, ready for RES', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Remote engine start code', + }), + 'context': , + 'entity_id': 'sensor.reg_number_remote_engine_start_code', + 'last_changed': , + 'last_updated': , + 'state': '10', + }), + ]) +# --- +# name: test_sensors[captur_phev] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777123', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Captur ii', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'XJB1SU', + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensors[captur_phev].1 + list([ + 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.reg_number_battery_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery level', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'battery_level', + 'unique_id': 'vf1aaaaa555777123_battery_level', + 'unit_of_measurement': '%', + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'not_in_charge', + 'waiting_for_a_planned_charge', + 'charge_ended', + 'waiting_for_current_charge', + 'energy_flap_opened', + 'charge_in_progress', + 'charge_error', + 'unavailable', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_charge_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Charge state', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'charge_state', + 'unique_id': 'vf1aaaaa555777123_charge_state', + 'unit_of_measurement': None, + }), + 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.reg_number_charging_remaining_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:timer', + 'original_name': 'Charging remaining time', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'charging_remaining_time', + 'unique_id': 'vf1aaaaa555777123_charging_remaining_time', + 'unit_of_measurement': , + }), + 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.reg_number_admissible_charging_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Admissible charging power', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'admissible_charging_power', + 'unique_id': 'vf1aaaaa555777123_charging_power', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'unplugged', + 'plugged', + 'plug_error', + 'plug_unknown', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_plug_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:power-plug', + 'original_name': 'Plug state', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'plug_state', + 'unique_id': 'vf1aaaaa555777123_plug_state', + 'unit_of_measurement': None, + }), + 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.reg_number_battery_autonomy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:ev-station', + 'original_name': 'Battery autonomy', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'battery_autonomy', + 'unique_id': 'vf1aaaaa555777123_battery_autonomy', + 'unit_of_measurement': , + }), + 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.reg_number_battery_available_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery available energy', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'battery_available_energy', + 'unique_id': 'vf1aaaaa555777123_battery_available_energy', + 'unit_of_measurement': , + }), + 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.reg_number_battery_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery temperature', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'battery_temperature', + 'unique_id': 'vf1aaaaa555777123_battery_temperature', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_last_battery_activity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last battery activity', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'battery_last_activity', + 'unique_id': 'vf1aaaaa555777123_battery_last_activity', + 'unit_of_measurement': None, + }), + 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.reg_number_mileage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:sign-direction', + 'original_name': 'Mileage', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'mileage', + 'unique_id': 'vf1aaaaa555777123_mileage', + 'unit_of_measurement': , + }), + 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.reg_number_fuel_autonomy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:gas-station', + 'original_name': 'Fuel autonomy', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'fuel_autonomy', + 'unique_id': 'vf1aaaaa555777123_fuel_autonomy', + 'unit_of_measurement': , + }), + 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.reg_number_fuel_quantity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:fuel', + 'original_name': 'Fuel quantity', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'fuel_quantity', + 'unique_id': 'vf1aaaaa555777123_fuel_quantity', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_last_location_activity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last location activity', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'location_last_activity', + 'unique_id': 'vf1aaaaa555777123_location_last_activity', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_remote_engine_start', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Remote engine start', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'res_state', + 'unique_id': 'vf1aaaaa555777123_res_state', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_remote_engine_start_code', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Remote engine start code', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'res_state_code', + 'unique_id': 'vf1aaaaa555777123_res_state_code', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_sensors[captur_phev].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'REG-NUMBER Battery level', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.reg_number_battery_level', + 'last_changed': , + 'last_updated': , + 'state': '60', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'REG-NUMBER Charge state', + 'icon': 'mdi:flash', + 'options': list([ + 'not_in_charge', + 'waiting_for_a_planned_charge', + 'charge_ended', + 'waiting_for_current_charge', + 'energy_flap_opened', + 'charge_in_progress', + 'charge_error', + 'unavailable', + ]), + }), + 'context': , + 'entity_id': 'sensor.reg_number_charge_state', + 'last_changed': , + 'last_updated': , + 'state': 'charge_in_progress', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Charging remaining time', + 'icon': 'mdi:timer', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_charging_remaining_time', + 'last_changed': , + 'last_updated': , + 'state': '145', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'REG-NUMBER Admissible charging power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_admissible_charging_power', + 'last_changed': , + 'last_updated': , + 'state': '27.0', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'REG-NUMBER Plug state', + 'icon': 'mdi:power-plug', + 'options': list([ + 'unplugged', + 'plugged', + 'plug_error', + 'plug_unknown', + ]), + }), + 'context': , + 'entity_id': 'sensor.reg_number_plug_state', + 'last_changed': , + 'last_updated': , + 'state': 'plugged', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-NUMBER Battery autonomy', + 'icon': 'mdi:ev-station', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_battery_autonomy', + 'last_changed': , + 'last_updated': , + 'state': '141', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'REG-NUMBER Battery available energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_battery_available_energy', + 'last_changed': , + 'last_updated': , + 'state': '31', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'REG-NUMBER Battery temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_battery_temperature', + 'last_changed': , + 'last_updated': , + 'state': '20', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'REG-NUMBER Last battery activity', + }), + 'context': , + 'entity_id': 'sensor.reg_number_last_battery_activity', + 'last_changed': , + 'last_updated': , + 'state': '2020-01-12T21:40:16+00:00', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-NUMBER Mileage', + 'icon': 'mdi:sign-direction', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_mileage', + 'last_changed': , + 'last_updated': , + 'state': '5567', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-NUMBER Fuel autonomy', + 'icon': 'mdi:gas-station', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_fuel_autonomy', + 'last_changed': , + 'last_updated': , + 'state': '35', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume', + 'friendly_name': 'REG-NUMBER Fuel quantity', + 'icon': 'mdi:fuel', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_fuel_quantity', + 'last_changed': , + 'last_updated': , + 'state': '3', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'REG-NUMBER Last location activity', + }), + 'context': , + 'entity_id': 'sensor.reg_number_last_location_activity', + 'last_changed': , + 'last_updated': , + 'state': '2020-02-18T16:58:38+00:00', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Remote engine start', + }), + 'context': , + 'entity_id': 'sensor.reg_number_remote_engine_start', + 'last_changed': , + 'last_updated': , + 'state': 'Stopped, ready for RES', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Remote engine start code', + }), + 'context': , + 'entity_id': 'sensor.reg_number_remote_engine_start_code', + 'last_changed': , + 'last_updated': , + 'state': '10', + }), + ]) +# --- +# name: test_sensors[zoe_40] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777999', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Zoe', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'X101VE', + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensors[zoe_40].1 + list([ + 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.reg_number_battery_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery level', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'battery_level', + 'unique_id': 'vf1aaaaa555777999_battery_level', + 'unit_of_measurement': '%', + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'not_in_charge', + 'waiting_for_a_planned_charge', + 'charge_ended', + 'waiting_for_current_charge', + 'energy_flap_opened', + 'charge_in_progress', + 'charge_error', + 'unavailable', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_charge_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Charge state', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'charge_state', + 'unique_id': 'vf1aaaaa555777999_charge_state', + 'unit_of_measurement': None, + }), + 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.reg_number_charging_remaining_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:timer', + 'original_name': 'Charging remaining time', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'charging_remaining_time', + 'unique_id': 'vf1aaaaa555777999_charging_remaining_time', + 'unit_of_measurement': , + }), + 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.reg_number_charging_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging power', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'charging_power', + 'unique_id': 'vf1aaaaa555777999_charging_power', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'unplugged', + 'plugged', + 'plug_error', + 'plug_unknown', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_plug_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:power-plug', + 'original_name': 'Plug state', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'plug_state', + 'unique_id': 'vf1aaaaa555777999_plug_state', + 'unit_of_measurement': None, + }), + 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.reg_number_battery_autonomy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:ev-station', + 'original_name': 'Battery autonomy', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'battery_autonomy', + 'unique_id': 'vf1aaaaa555777999_battery_autonomy', + 'unit_of_measurement': , + }), + 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.reg_number_battery_available_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery available energy', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'battery_available_energy', + 'unique_id': 'vf1aaaaa555777999_battery_available_energy', + 'unit_of_measurement': , + }), + 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.reg_number_battery_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery temperature', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'battery_temperature', + 'unique_id': 'vf1aaaaa555777999_battery_temperature', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_last_battery_activity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last battery activity', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'battery_last_activity', + 'unique_id': 'vf1aaaaa555777999_battery_last_activity', + 'unit_of_measurement': None, + }), + 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.reg_number_mileage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:sign-direction', + 'original_name': 'Mileage', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'mileage', + 'unique_id': 'vf1aaaaa555777999_mileage', + 'unit_of_measurement': , + }), + 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.reg_number_outside_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Outside temperature', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'outside_temperature', + 'unique_id': 'vf1aaaaa555777999_outside_temperature', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_hvac_soc_threshold', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HVAC SoC threshold', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'hvac_soc_threshold', + 'unique_id': 'vf1aaaaa555777999_hvac_soc_threshold', + 'unit_of_measurement': '%', + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_last_hvac_activity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last HVAC activity', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'hvac_last_activity', + 'unique_id': 'vf1aaaaa555777999_hvac_last_activity', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_remote_engine_start', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Remote engine start', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'res_state', + 'unique_id': 'vf1aaaaa555777999_res_state', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_remote_engine_start_code', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Remote engine start code', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'res_state_code', + 'unique_id': 'vf1aaaaa555777999_res_state_code', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_sensors[zoe_40].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'REG-NUMBER Battery level', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.reg_number_battery_level', + 'last_changed': , + 'last_updated': , + 'state': '60', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'REG-NUMBER Charge state', + 'icon': 'mdi:flash', + 'options': list([ + 'not_in_charge', + 'waiting_for_a_planned_charge', + 'charge_ended', + 'waiting_for_current_charge', + 'energy_flap_opened', + 'charge_in_progress', + 'charge_error', + 'unavailable', + ]), + }), + 'context': , + 'entity_id': 'sensor.reg_number_charge_state', + 'last_changed': , + 'last_updated': , + 'state': 'charge_in_progress', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Charging remaining time', + 'icon': 'mdi:timer', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_charging_remaining_time', + 'last_changed': , + 'last_updated': , + 'state': '145', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'REG-NUMBER Charging power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_charging_power', + 'last_changed': , + 'last_updated': , + 'state': '0.027', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'REG-NUMBER Plug state', + 'icon': 'mdi:power-plug', + 'options': list([ + 'unplugged', + 'plugged', + 'plug_error', + 'plug_unknown', + ]), + }), + 'context': , + 'entity_id': 'sensor.reg_number_plug_state', + 'last_changed': , + 'last_updated': , + 'state': 'plugged', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-NUMBER Battery autonomy', + 'icon': 'mdi:ev-station', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_battery_autonomy', + 'last_changed': , + 'last_updated': , + 'state': '141', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'REG-NUMBER Battery available energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_battery_available_energy', + 'last_changed': , + 'last_updated': , + 'state': '31', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'REG-NUMBER Battery temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_battery_temperature', + 'last_changed': , + 'last_updated': , + 'state': '20', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'REG-NUMBER Last battery activity', + }), + 'context': , + 'entity_id': 'sensor.reg_number_last_battery_activity', + 'last_changed': , + 'last_updated': , + 'state': '2020-01-12T21:40:16+00:00', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-NUMBER Mileage', + 'icon': 'mdi:sign-direction', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_mileage', + 'last_changed': , + 'last_updated': , + 'state': '49114', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'REG-NUMBER Outside temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_outside_temperature', + 'last_changed': , + 'last_updated': , + 'state': '8.0', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER HVAC SoC threshold', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.reg_number_hvac_soc_threshold', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'REG-NUMBER Last HVAC activity', + }), + 'context': , + 'entity_id': 'sensor.reg_number_last_hvac_activity', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Remote engine start', + }), + 'context': , + 'entity_id': 'sensor.reg_number_remote_engine_start', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Remote engine start code', + }), + 'context': , + 'entity_id': 'sensor.reg_number_remote_engine_start_code', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + ]) +# --- +# name: test_sensors[zoe_50] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777999', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Zoe', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'X102VE', + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensors[zoe_50].1 + list([ + 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.reg_number_battery_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery level', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'battery_level', + 'unique_id': 'vf1aaaaa555777999_battery_level', + 'unit_of_measurement': '%', + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'not_in_charge', + 'waiting_for_a_planned_charge', + 'charge_ended', + 'waiting_for_current_charge', + 'energy_flap_opened', + 'charge_in_progress', + 'charge_error', + 'unavailable', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_charge_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:flash-off', + 'original_name': 'Charge state', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'charge_state', + 'unique_id': 'vf1aaaaa555777999_charge_state', + 'unit_of_measurement': None, + }), + 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.reg_number_charging_remaining_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:timer', + 'original_name': 'Charging remaining time', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'charging_remaining_time', + 'unique_id': 'vf1aaaaa555777999_charging_remaining_time', + 'unit_of_measurement': , + }), + 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.reg_number_admissible_charging_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Admissible charging power', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'admissible_charging_power', + 'unique_id': 'vf1aaaaa555777999_charging_power', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'unplugged', + 'plugged', + 'plug_error', + 'plug_unknown', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_plug_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:power-plug-off', + 'original_name': 'Plug state', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'plug_state', + 'unique_id': 'vf1aaaaa555777999_plug_state', + 'unit_of_measurement': None, + }), + 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.reg_number_battery_autonomy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:ev-station', + 'original_name': 'Battery autonomy', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'battery_autonomy', + 'unique_id': 'vf1aaaaa555777999_battery_autonomy', + 'unit_of_measurement': , + }), + 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.reg_number_battery_available_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery available energy', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'battery_available_energy', + 'unique_id': 'vf1aaaaa555777999_battery_available_energy', + 'unit_of_measurement': , + }), + 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.reg_number_battery_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery temperature', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'battery_temperature', + 'unique_id': 'vf1aaaaa555777999_battery_temperature', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_last_battery_activity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last battery activity', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'battery_last_activity', + 'unique_id': 'vf1aaaaa555777999_battery_last_activity', + 'unit_of_measurement': None, + }), + 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.reg_number_mileage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:sign-direction', + 'original_name': 'Mileage', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'mileage', + 'unique_id': 'vf1aaaaa555777999_mileage', + 'unit_of_measurement': , + }), + 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.reg_number_outside_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Outside temperature', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'outside_temperature', + 'unique_id': 'vf1aaaaa555777999_outside_temperature', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_hvac_soc_threshold', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HVAC SoC threshold', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'hvac_soc_threshold', + 'unique_id': 'vf1aaaaa555777999_hvac_soc_threshold', + 'unit_of_measurement': '%', + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_last_hvac_activity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last HVAC activity', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'hvac_last_activity', + 'unique_id': 'vf1aaaaa555777999_hvac_last_activity', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_last_location_activity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last location activity', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'location_last_activity', + 'unique_id': 'vf1aaaaa555777999_location_last_activity', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_remote_engine_start', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Remote engine start', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'res_state', + 'unique_id': 'vf1aaaaa555777999_res_state', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_remote_engine_start_code', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Remote engine start code', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'res_state_code', + 'unique_id': 'vf1aaaaa555777999_res_state_code', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_sensors[zoe_50].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'REG-NUMBER Battery level', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.reg_number_battery_level', + 'last_changed': , + 'last_updated': , + 'state': '50', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'REG-NUMBER Charge state', + 'icon': 'mdi:flash-off', + 'options': list([ + 'not_in_charge', + 'waiting_for_a_planned_charge', + 'charge_ended', + 'waiting_for_current_charge', + 'energy_flap_opened', + 'charge_in_progress', + 'charge_error', + 'unavailable', + ]), + }), + 'context': , + 'entity_id': 'sensor.reg_number_charge_state', + 'last_changed': , + 'last_updated': , + 'state': 'charge_error', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Charging remaining time', + 'icon': 'mdi:timer', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_charging_remaining_time', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'REG-NUMBER Admissible charging power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_admissible_charging_power', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'REG-NUMBER Plug state', + 'icon': 'mdi:power-plug-off', + 'options': list([ + 'unplugged', + 'plugged', + 'plug_error', + 'plug_unknown', + ]), + }), + 'context': , + 'entity_id': 'sensor.reg_number_plug_state', + 'last_changed': , + 'last_updated': , + 'state': 'unplugged', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-NUMBER Battery autonomy', + 'icon': 'mdi:ev-station', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_battery_autonomy', + 'last_changed': , + 'last_updated': , + 'state': '128', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'REG-NUMBER Battery available energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_battery_available_energy', + 'last_changed': , + 'last_updated': , + 'state': '0', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'REG-NUMBER Battery temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_battery_temperature', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'REG-NUMBER Last battery activity', + }), + 'context': , + 'entity_id': 'sensor.reg_number_last_battery_activity', + 'last_changed': , + 'last_updated': , + 'state': '2020-11-17T08:06:48+00:00', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-NUMBER Mileage', + 'icon': 'mdi:sign-direction', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_mileage', + 'last_changed': , + 'last_updated': , + 'state': '49114', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'REG-NUMBER Outside temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_outside_temperature', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER HVAC SoC threshold', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.reg_number_hvac_soc_threshold', + 'last_changed': , + 'last_updated': , + 'state': '30.0', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'REG-NUMBER Last HVAC activity', + }), + 'context': , + 'entity_id': 'sensor.reg_number_last_hvac_activity', + 'last_changed': , + 'last_updated': , + 'state': '2020-12-03T00:00:00+00:00', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'REG-NUMBER Last location activity', + }), + 'context': , + 'entity_id': 'sensor.reg_number_last_location_activity', + 'last_changed': , + 'last_updated': , + 'state': '2020-02-18T16:58:38+00:00', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Remote engine start', + }), + 'context': , + 'entity_id': 'sensor.reg_number_remote_engine_start', + 'last_changed': , + 'last_updated': , + 'state': 'Stopped, ready for RES', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Remote engine start code', + }), + 'context': , + 'entity_id': 'sensor.reg_number_remote_engine_start_code', + 'last_changed': , + 'last_updated': , + 'state': '10', + }), + ]) +# --- diff --git a/tests/components/renault/test_binary_sensor.py b/tests/components/renault/test_binary_sensor.py index 2a332d655d0e..320c5cc30d59 100644 --- a/tests/components/renault/test_binary_sensor.py +++ b/tests/components/renault/test_binary_sensor.py @@ -3,18 +3,14 @@ from collections.abc import Generator from unittest.mock import patch import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from . import ( - check_device_registry, - check_entities, - check_entities_no_data, - check_entities_unavailable, -) +from . import check_device_registry, check_entities_no_data, check_entities_unavailable from .const import MOCK_VEHICLES pytestmark = pytest.mark.usefixtures("patch_renault_account", "patch_get_vehicles") @@ -31,21 +27,29 @@ def override_platforms() -> Generator[None, None, None]: async def test_binary_sensors( hass: HomeAssistant, config_entry: ConfigEntry, - vehicle_type: str, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test for Renault binary sensors.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - mock_vehicle = MOCK_VEHICLES[vehicle_type] - check_device_registry(device_registry, mock_vehicle["expected_device"]) + # Ensure devices are correctly registered + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + assert device_entries == snapshot - expected_entities = mock_vehicle[Platform.BINARY_SENSOR] - assert len(entity_registry.entities) == len(expected_entities) + # Ensure entities are correctly registered + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + assert entity_entries == snapshot - check_entities(hass, entity_registry, expected_entities) + # Ensure entity states are correct + states = [hass.states.get(ent.entity_id) for ent in entity_entries] + assert states == snapshot @pytest.mark.usefixtures("fixtures_with_no_data") diff --git a/tests/components/renault/test_button.py b/tests/components/renault/test_button.py index 695be73089ef..576e3c05c2ae 100644 --- a/tests/components/renault/test_button.py +++ b/tests/components/renault/test_button.py @@ -4,6 +4,7 @@ from unittest.mock import patch import pytest from renault_api.kamereon import schemas +from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.config_entries import ConfigEntry @@ -30,21 +31,29 @@ def override_platforms() -> Generator[None, None, None]: async def test_buttons( hass: HomeAssistant, config_entry: ConfigEntry, - vehicle_type: str, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test for Renault device trackers.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - mock_vehicle = MOCK_VEHICLES[vehicle_type] - check_device_registry(device_registry, mock_vehicle["expected_device"]) + # Ensure devices are correctly registered + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + assert device_entries == snapshot - expected_entities = mock_vehicle[Platform.BUTTON] - assert len(entity_registry.entities) == len(expected_entities) + # Ensure entities are correctly registered + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + assert entity_entries == snapshot - check_entities_no_data(hass, entity_registry, expected_entities, STATE_UNKNOWN) + # Ensure entity states are correct + states = [hass.states.get(ent.entity_id) for ent in entity_entries] + assert states == snapshot @pytest.mark.usefixtures("fixtures_with_no_data") diff --git a/tests/components/renault/test_device_tracker.py b/tests/components/renault/test_device_tracker.py index 8a186fbe9d57..910524422ca9 100644 --- a/tests/components/renault/test_device_tracker.py +++ b/tests/components/renault/test_device_tracker.py @@ -3,18 +3,14 @@ from collections.abc import Generator from unittest.mock import patch import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from . import ( - check_device_registry, - check_entities, - check_entities_no_data, - check_entities_unavailable, -) +from . import check_device_registry, check_entities_no_data, check_entities_unavailable from .const import MOCK_VEHICLES pytestmark = pytest.mark.usefixtures("patch_renault_account", "patch_get_vehicles") @@ -31,21 +27,29 @@ def override_platforms() -> Generator[None, None, None]: async def test_device_trackers( hass: HomeAssistant, config_entry: ConfigEntry, - vehicle_type: str, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test for Renault device trackers.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - mock_vehicle = MOCK_VEHICLES[vehicle_type] - check_device_registry(device_registry, mock_vehicle["expected_device"]) + # Ensure devices are correctly registered + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + assert device_entries == snapshot - expected_entities = mock_vehicle[Platform.DEVICE_TRACKER] - assert len(entity_registry.entities) == len(expected_entities) + # Ensure entities are correctly registered + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + assert entity_entries == snapshot - check_entities(hass, entity_registry, expected_entities) + # Ensure entity states are correct + states = [hass.states.get(ent.entity_id) for ent in entity_entries] + assert states == snapshot @pytest.mark.usefixtures("fixtures_with_no_data") diff --git a/tests/components/renault/test_select.py b/tests/components/renault/test_select.py index 2179d6783764..9b5a6b014528 100644 --- a/tests/components/renault/test_select.py +++ b/tests/components/renault/test_select.py @@ -4,6 +4,7 @@ from unittest.mock import patch import pytest from renault_api.kamereon import schemas +from syrupy.assertion import SnapshotAssertion from homeassistant.components.select import ( ATTR_OPTION, @@ -15,12 +16,7 @@ from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from . import ( - check_device_registry, - check_entities, - check_entities_no_data, - check_entities_unavailable, -) +from . import check_device_registry, check_entities_no_data, check_entities_unavailable from .const import MOCK_VEHICLES from tests.common import load_fixture @@ -39,21 +35,29 @@ def override_platforms() -> Generator[None, None, None]: async def test_selects( hass: HomeAssistant, config_entry: ConfigEntry, - vehicle_type: str, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test for Renault selects.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - mock_vehicle = MOCK_VEHICLES[vehicle_type] - check_device_registry(device_registry, mock_vehicle["expected_device"]) + # Ensure devices are correctly registered + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + assert device_entries == snapshot - expected_entities = mock_vehicle[Platform.SELECT] - assert len(entity_registry.entities) == len(expected_entities) + # Ensure entities are correctly registered + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + assert entity_entries == snapshot - check_entities(hass, entity_registry, expected_entities) + # Ensure entity states are correct + states = [hass.states.get(ent.entity_id) for ent in entity_entries] + assert states == snapshot @pytest.mark.usefixtures("fixtures_with_no_data") diff --git a/tests/components/renault/test_sensor.py b/tests/components/renault/test_sensor.py index 8220a0bf6aaf..e0462834ae47 100644 --- a/tests/components/renault/test_sensor.py +++ b/tests/components/renault/test_sensor.py @@ -1,22 +1,17 @@ """Tests for Renault sensors.""" from collections.abc import Generator -from types import MappingProxyType from unittest.mock import patch import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform +from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from . import ( - check_device_registry, - check_entities, - check_entities_no_data, - check_entities_unavailable, -) -from .const import ATTR_DEFAULT_DISABLED, MOCK_VEHICLES +from . import check_device_registry, check_entities_no_data, check_entities_unavailable +from .const import MOCK_VEHICLES pytestmark = pytest.mark.usefixtures("patch_renault_account", "patch_get_vehicles") @@ -28,46 +23,42 @@ def override_platforms() -> Generator[None, None, None]: yield -def _check_and_enable_disabled_entities( - entity_registry: er.EntityRegistry, expected_entities: MappingProxyType -) -> None: - """Ensure that the expected_entities are correctly disabled.""" - for expected_entity in expected_entities: - if expected_entity.get(ATTR_DEFAULT_DISABLED): - entity_id = expected_entity[ATTR_ENTITY_ID] - registry_entry = entity_registry.entities.get(entity_id) - assert registry_entry, f"{entity_id} not found in registry" - assert registry_entry.disabled - assert registry_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION - entity_registry.async_update_entity(entity_id, **{"disabled_by": None}) - - @pytest.mark.usefixtures("fixtures_with_data") async def test_sensors( hass: HomeAssistant, config_entry: ConfigEntry, - vehicle_type: str, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test for Renault sensors.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - mock_vehicle = MOCK_VEHICLES[vehicle_type] - check_device_registry(device_registry, mock_vehicle["expected_device"]) + # Ensure devices are correctly registered + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + assert device_entries == snapshot - expected_entities = mock_vehicle[Platform.SENSOR] - assert len(entity_registry.entities) == len(expected_entities) + # Ensure entities are correctly registered + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + assert entity_entries == snapshot - _check_and_enable_disabled_entities(entity_registry, expected_entities) + # Some entities are disabled, enable them and reload before checking states + for ent in entity_entries: + entity_registry.async_update_entity(ent.entity_id, **{"disabled_by": None}) await hass.config_entries.async_reload(config_entry.entry_id) await hass.async_block_till_done() - check_entities(hass, entity_registry, expected_entities) + # Ensure entity states are correct + states = [hass.states.get(ent.entity_id) for ent in entity_entries] + assert states == snapshot -@pytest.mark.usefixtures("fixtures_with_no_data") +@pytest.mark.usefixtures("fixtures_with_no_data", "entity_registry_enabled_by_default") async def test_sensor_empty( hass: HomeAssistant, config_entry: ConfigEntry, @@ -85,14 +76,12 @@ async def test_sensor_empty( expected_entities = mock_vehicle[Platform.SENSOR] assert len(entity_registry.entities) == len(expected_entities) - _check_and_enable_disabled_entities(entity_registry, expected_entities) - await hass.config_entries.async_reload(config_entry.entry_id) - await hass.async_block_till_done() - check_entities_no_data(hass, entity_registry, expected_entities, STATE_UNKNOWN) -@pytest.mark.usefixtures("fixtures_with_invalid_upstream_exception") +@pytest.mark.usefixtures( + "fixtures_with_invalid_upstream_exception", "entity_registry_enabled_by_default" +) async def test_sensor_errors( hass: HomeAssistant, config_entry: ConfigEntry, @@ -110,7 +99,6 @@ async def test_sensor_errors( expected_entities = mock_vehicle[Platform.SENSOR] assert len(entity_registry.entities) == len(expected_entities) - _check_and_enable_disabled_entities(entity_registry, expected_entities) await hass.config_entries.async_reload(config_entry.entry_id) await hass.async_block_till_done() From f95f2d1cfc4ddee79ae4321a6583425171908195 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 30 Apr 2023 13:13:47 -0500 Subject: [PATCH 108/822] Prevent pysnmp from being installed as it does not work with newer python (#92292) --- homeassistant/components/aten_pe/switch.py | 2 +- homeassistant/package_constraints.txt | 3 +++ requirements_all.txt | 2 +- script/gen_requirements_all.py | 4 ++++ 4 files changed, 9 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/aten_pe/switch.py b/homeassistant/components/aten_pe/switch.py index d49201f6d7b1..cdf45db035ce 100644 --- a/homeassistant/components/aten_pe/switch.py +++ b/homeassistant/components/aten_pe/switch.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging from typing import Any -from atenpdu import AtenPE, AtenPEError +from atenpdu import AtenPE, AtenPEError # pylint: disable=import-error import voluptuous as vol from homeassistant.components.switch import ( diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 78121963cf51..2593d34e1b45 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -176,3 +176,6 @@ websockets>=11.0.1 # https://github.com/pysnmp/pysnmp/issues/51 pyasn1==0.4.8 pysnmplib==5.0.21 +# pysnmp is no longer maintained and does not work with newer +# python +pysnmp==1000000000.0.0 diff --git a/requirements_all.txt b/requirements_all.txt index ea12f37e0327..574d6573abef 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -386,7 +386,7 @@ asyncpysupla==0.0.5 asyncsleepiq==1.3.4 # homeassistant.components.aten_pe -atenpdu==0.3.2 +# atenpdu==0.3.2 # homeassistant.components.aurora auroranoaa==0.0.3 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 592e8f5a1f0c..f3479d477891 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -21,6 +21,7 @@ else: COMMENT_REQUIREMENTS = ( "Adafruit_BBIO", + "atenpdu", # depends on pysnmp which is not maintained at this time "avea", # depends on bluepy "avion", "azure-servicebus", # depends on uamqp, which requires OpenSSL 1.1 @@ -180,6 +181,9 @@ websockets>=11.0.1 # https://github.com/pysnmp/pysnmp/issues/51 pyasn1==0.4.8 pysnmplib==5.0.21 +# pysnmp is no longer maintained and does not work with newer +# python +pysnmp==1000000000.0.0 """ IGNORE_PRE_COMMIT_HOOK_ID = ( From d138bbe26efed9b6ca299f0833790b5f05245b8c Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Sun, 30 Apr 2023 14:34:57 -0400 Subject: [PATCH 109/822] Add `date` platform (#81948) --- CODEOWNERS | 2 + homeassistant/components/date/__init__.py | 109 ++++++++++++++++++ homeassistant/components/date/const.py | 5 + homeassistant/components/date/manifest.json | 8 ++ homeassistant/components/date/services.yaml | 14 +++ homeassistant/components/date/strings.json | 8 ++ homeassistant/components/demo/__init__.py | 1 + homeassistant/components/demo/date.py | 73 ++++++++++++ homeassistant/const.py | 1 + tests/components/date/__init__.py | 1 + tests/components/date/test_init.py | 54 +++++++++ tests/components/demo/test_date.py | 34 ++++++ .../custom_components/test/date.py | 50 ++++++++ 13 files changed, 360 insertions(+) create mode 100644 homeassistant/components/date/__init__.py create mode 100644 homeassistant/components/date/const.py create mode 100644 homeassistant/components/date/manifest.json create mode 100644 homeassistant/components/date/services.yaml create mode 100644 homeassistant/components/date/strings.json create mode 100644 homeassistant/components/demo/date.py create mode 100644 tests/components/date/__init__.py create mode 100644 tests/components/date/test_init.py create mode 100644 tests/components/demo/test_date.py create mode 100644 tests/testing_config/custom_components/test/date.py diff --git a/CODEOWNERS b/CODEOWNERS index b059808408bb..11cdf013aae1 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -234,6 +234,8 @@ build.json @home-assistant/supervisor /homeassistant/components/cups/ @fabaff /homeassistant/components/daikin/ @fredrike /tests/components/daikin/ @fredrike +/homeassistant/components/date/ @home-assistant/core +/tests/components/date/ @home-assistant/core /homeassistant/components/debugpy/ @frenck /tests/components/debugpy/ @frenck /homeassistant/components/deconz/ @Kane610 diff --git a/homeassistant/components/date/__init__.py b/homeassistant/components/date/__init__.py new file mode 100644 index 000000000000..51f3a492c47a --- /dev/null +++ b/homeassistant/components/date/__init__.py @@ -0,0 +1,109 @@ +"""Component to allow setting date as platforms.""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date, timedelta +import logging +from typing import final + +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_DATE +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.config_validation import ( # noqa: F401 + PLATFORM_SCHEMA, + PLATFORM_SCHEMA_BASE, +) +from homeassistant.helpers.entity import Entity, EntityDescription +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.typing import ConfigType + +from .const import DOMAIN, SERVICE_SET_VALUE + +SCAN_INTERVAL = timedelta(seconds=30) + +ENTITY_ID_FORMAT = DOMAIN + ".{}" + +_LOGGER = logging.getLogger(__name__) + +__all__ = ["DOMAIN", "DateEntity", "DateEntityDescription"] + + +async def _async_set_value(entity: DateEntity, service_call: ServiceCall) -> None: + """Service call wrapper to set a new date.""" + return await entity.async_set_value(service_call.data[ATTR_DATE]) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up Date entities.""" + component = hass.data[DOMAIN] = EntityComponent[DateEntity]( + _LOGGER, DOMAIN, hass, SCAN_INTERVAL + ) + await component.async_setup(config) + + component.async_register_entity_service( + SERVICE_SET_VALUE, {vol.Required(ATTR_DATE): cv.date}, _async_set_value + ) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a config entry.""" + component: EntityComponent[DateEntity] = hass.data[DOMAIN] + return await component.async_setup_entry(entry) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + component: EntityComponent[DateEntity] = hass.data[DOMAIN] + return await component.async_unload_entry(entry) + + +@dataclass +class DateEntityDescription(EntityDescription): + """A class that describes date entities.""" + + +class DateEntity(Entity): + """Representation of a Date entity.""" + + entity_description: DateEntityDescription + _attr_device_class: None + _attr_native_value: date | None + _attr_state: None = None + + @property + @final + def device_class(self) -> None: + """Return the device class for the entity.""" + return None + + @property + @final + def state_attributes(self) -> None: + """Return the state attributes.""" + return None + + @property + @final + def state(self) -> str | None: + """Return the entity state.""" + if self.native_value is None: + return None + return self.native_value.isoformat() + + @property + def native_value(self) -> date | None: + """Return the value reported by the date.""" + return self._attr_native_value + + def set_value(self, value: date) -> None: + """Change the date.""" + raise NotImplementedError() + + async def async_set_value(self, value: date) -> None: + """Change the date.""" + await self.hass.async_add_executor_job(self.set_value, value) diff --git a/homeassistant/components/date/const.py b/homeassistant/components/date/const.py new file mode 100644 index 000000000000..aa87b330e033 --- /dev/null +++ b/homeassistant/components/date/const.py @@ -0,0 +1,5 @@ +"""Provides the constants needed for the component.""" + +DOMAIN = "date" + +SERVICE_SET_VALUE = "set_value" diff --git a/homeassistant/components/date/manifest.json b/homeassistant/components/date/manifest.json new file mode 100644 index 000000000000..f0e51390ebfc --- /dev/null +++ b/homeassistant/components/date/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "date", + "name": "Date", + "codeowners": ["@home-assistant/core"], + "documentation": "https://www.home-assistant.io/integrations/date", + "integration_type": "entity", + "quality_scale": "internal" +} diff --git a/homeassistant/components/date/services.yaml b/homeassistant/components/date/services.yaml new file mode 100644 index 000000000000..7ce1210f8092 --- /dev/null +++ b/homeassistant/components/date/services.yaml @@ -0,0 +1,14 @@ +set_value: + name: Set Date + description: Set the date for a date entity. + target: + entity: + domain: date + fields: + date: + name: Date + description: The date to set. + required: true + example: "2022/11/01" + selector: + date: diff --git a/homeassistant/components/date/strings.json b/homeassistant/components/date/strings.json new file mode 100644 index 000000000000..110a4cabb921 --- /dev/null +++ b/homeassistant/components/date/strings.json @@ -0,0 +1,8 @@ +{ + "title": "Date", + "entity_component": { + "_": { + "name": "[%key:component::date::title%]" + } + } +} diff --git a/homeassistant/components/demo/__init__.py b/homeassistant/components/demo/__init__.py index 82cb8eff625c..45ec00e48466 100644 --- a/homeassistant/components/demo/__init__.py +++ b/homeassistant/components/demo/__init__.py @@ -27,6 +27,7 @@ COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM = [ Platform.CAMERA, Platform.CLIMATE, Platform.COVER, + Platform.DATE, Platform.FAN, Platform.HUMIDIFIER, Platform.LIGHT, diff --git a/homeassistant/components/demo/date.py b/homeassistant/components/demo/date.py new file mode 100644 index 000000000000..eb96bc490382 --- /dev/null +++ b/homeassistant/components/demo/date.py @@ -0,0 +1,73 @@ +"""Demo platform that offers a fake Date entity.""" +from __future__ import annotations + +from datetime import date + +from homeassistant.components.date import DateEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import DEVICE_DEFAULT_NAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType + +from . import DOMAIN + + +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up the Demo date entity.""" + async_add_entities( + [ + DemoDate( + "date", + "Date", + date(2020, 1, 1), + "mdi:calendar", + False, + ), + ] + ) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Demo config entry.""" + await async_setup_platform(hass, {}, async_add_entities) + + +class DemoDate(DateEntity): + """Representation of a Demo date entity.""" + + _attr_should_poll = False + + def __init__( + self, + unique_id: str, + name: str, + state: date, + icon: str, + assumed_state: bool, + ) -> None: + """Initialize the Demo date entity.""" + self._attr_assumed_state = assumed_state + self._attr_icon = icon + self._attr_name = name or DEVICE_DEFAULT_NAME + self._attr_native_value = state + self._attr_unique_id = unique_id + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, name=self.name + ) + + async def async_set_value(self, value: date) -> None: + """Update the date.""" + self._attr_native_value = value + self.async_write_ha_state() diff --git a/homeassistant/const.py b/homeassistant/const.py index 4f1586fc3032..ad6a25760990 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -31,6 +31,7 @@ class Platform(StrEnum): CAMERA = "camera" CLIMATE = "climate" COVER = "cover" + DATE = "date" DEVICE_TRACKER = "device_tracker" FAN = "fan" GEO_LOCATION = "geo_location" diff --git a/tests/components/date/__init__.py b/tests/components/date/__init__.py new file mode 100644 index 000000000000..269734003d9b --- /dev/null +++ b/tests/components/date/__init__.py @@ -0,0 +1 @@ +"""Tests for the date component.""" diff --git a/tests/components/date/test_init.py b/tests/components/date/test_init.py new file mode 100644 index 000000000000..2ae176731198 --- /dev/null +++ b/tests/components/date/test_init.py @@ -0,0 +1,54 @@ +"""The tests for the date component.""" +from datetime import date + +from homeassistant.components.date import DOMAIN, SERVICE_SET_VALUE, DateEntity +from homeassistant.const import ( + ATTR_DATE, + ATTR_ENTITY_ID, + ATTR_FRIENDLY_NAME, + CONF_PLATFORM, +) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + + +class MockDateEntity(DateEntity): + """Mock date device to use in tests.""" + + _attr_name = "date" + + def __init__(self, native_value=date(2020, 1, 1)) -> None: + """Initialize mock date entity.""" + self._attr_native_value = native_value + + async def async_set_value(self, value: date) -> None: + """Set the value of the date.""" + self._attr_native_value = value + + +async def test_date(hass: HomeAssistant, enable_custom_integrations: None) -> None: + """Test date entity.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() + + state = hass.states.get("date.test") + assert state.state == "2020-01-01" + assert state.attributes == {ATTR_FRIENDLY_NAME: "test"} + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + {ATTR_DATE: date(2021, 1, 1), ATTR_ENTITY_ID: "date.test"}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("date.test") + assert state.state == "2021-01-01" + + date_entity = MockDateEntity(native_value=None) + assert date_entity.state is None + assert date_entity.state_attributes is None diff --git a/tests/components/demo/test_date.py b/tests/components/demo/test_date.py new file mode 100644 index 000000000000..c42ba06667e7 --- /dev/null +++ b/tests/components/demo/test_date.py @@ -0,0 +1,34 @@ +"""The tests for the demo date component.""" +import pytest + +from homeassistant.components.date import ATTR_DATE, DOMAIN, SERVICE_SET_VALUE +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +ENTITY_DATE = "date.date" + + +@pytest.fixture(autouse=True) +async def setup_demo_date(hass: HomeAssistant) -> None: + """Initialize setup demo date.""" + assert await async_setup_component(hass, DOMAIN, {"date": {"platform": "demo"}}) + await hass.async_block_till_done() + + +def test_setup_params(hass: HomeAssistant) -> None: + """Test the initial parameters.""" + state = hass.states.get(ENTITY_DATE) + assert state.state == "2020-01-01" + + +async def test_set_datetime(hass: HomeAssistant) -> None: + """Test set datetime service.""" + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: ENTITY_DATE, ATTR_DATE: "2021-02-03"}, + blocking=True, + ) + state = hass.states.get(ENTITY_DATE) + assert state.state == "2021-02-03" diff --git a/tests/testing_config/custom_components/test/date.py b/tests/testing_config/custom_components/test/date.py new file mode 100644 index 000000000000..b35be6f19192 --- /dev/null +++ b/tests/testing_config/custom_components/test/date.py @@ -0,0 +1,50 @@ +"""Provide a mock date platform. + +Call init before using it in your tests to ensure clean test data. +""" +from datetime import date + +from homeassistant.components.date import DateEntity + +from tests.common import MockEntity + +UNIQUE_DATE = "unique_date" + +ENTITIES = [] + + +class MockDateEntity(MockEntity, DateEntity): + """Mock date class.""" + + @property + def native_value(self): + """Return the native value of this date.""" + return self._handle("native_value") + + def set_value(self, value: date) -> None: + """Change the date.""" + self._values["native_value"] = value + + +def init(empty=False): + """Initialize the platform with entities.""" + global ENTITIES + + ENTITIES = ( + [] + if empty + else [ + MockDateEntity( + name="test", + unique_id=UNIQUE_DATE, + native_value=date(2020, 1, 1), + ), + ] + ) + + +async def async_setup_platform( + hass, config, async_add_entities_callback, discovery_info=None +): + """Return mock entities.""" + async_add_entities_callback(ENTITIES) From bb4c03ce3cd6ebeb9fb6853e4ca1bdb0e866df17 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 30 Apr 2023 13:46:26 -0500 Subject: [PATCH 110/822] Avoid returning statistics columns that the metadata knows are impossible (#92095) --- .../components/recorder/statistics.py | 33 ++++-- homeassistant/components/sensor/recorder.py | 7 +- tests/components/recorder/test_statistics.py | 109 +++++++----------- .../components/recorder/test_websocket_api.py | 63 ++-------- tests/components/tibber/test_statistics.py | 3 - 5 files changed, 83 insertions(+), 132 deletions(-) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 06dd20defbd5..b34d497b4b77 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -1518,6 +1518,28 @@ def _generate_select_columns_for_types_stmt( return lambda_stmt(lambda: columns, track_on=track_on) +def _extract_metadata_and_discard_impossible_columns( + metadata: dict[str, tuple[int, StatisticMetaData]], + types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], +) -> list[int]: + """Extract metadata ids from metadata and discard impossible columns.""" + metadata_ids = [] + has_mean = False + has_sum = False + for metadata_id, stats_metadata in metadata.values(): + metadata_ids.append(metadata_id) + has_mean |= stats_metadata["has_mean"] + has_sum |= stats_metadata["has_sum"] + if not has_mean: + types.discard("mean") + types.discard("min") + types.discard("max") + if not has_sum: + types.discard("sum") + types.discard("state") + return metadata_ids + + def _statistics_during_period_with_session( hass: HomeAssistant, session: Session, @@ -1547,7 +1569,7 @@ def _statistics_during_period_with_session( metadata_ids = None if statistic_ids is not None: - metadata_ids = [metadata_id for metadata_id, _ in metadata.values()] + metadata_ids = _extract_metadata_and_discard_impossible_columns(metadata, types) table: type[Statistics | StatisticsShortTerm] = ( Statistics if period != "5minute" else StatisticsShortTerm @@ -1661,7 +1683,8 @@ def _get_last_statistics( ) if not metadata: return {} - metadata_id = metadata[statistic_id][0] + metadata_ids = _extract_metadata_and_discard_impossible_columns(metadata, types) + metadata_id = metadata_ids[0] if table == Statistics: stmt = _get_last_statistics_stmt(metadata_id, number_of_stats) else: @@ -1753,11 +1776,7 @@ def get_latest_short_term_statistics( ) if not metadata: return {} - metadata_ids = [ - metadata[statistic_id][0] - for statistic_id in statistic_ids - if statistic_id in metadata - ] + 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) diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 21fbf453ac3f..f9fdc2525371 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -557,8 +557,11 @@ def _compile_statistics( # noqa: C901 last_stat = last_stats[entity_id][0] last_reset = _timestamp_to_isoformat_or_none(last_stat["last_reset"]) old_last_reset = last_reset - new_state = old_state = last_stat["state"] - _sum = last_stat["sum"] or 0.0 + # If there are no previous values and has_sum + # was previously false there will be no last_stat + # for state or sum + new_state = old_state = last_stat.get("state") + _sum = last_stat.get("sum") or 0.0 for fstate, state in valid_float_states: reset = False diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index 59178f52c8be..329ceba6e777 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -92,6 +92,12 @@ def test_compile_hourly_statistics(hass_recorder: Callable[..., HomeAssistant]) do_adhoc_statistics(hass, start=zero) do_adhoc_statistics(hass, start=four) wait_recording_done(hass) + + metadata = get_metadata(hass, statistic_ids={"sensor.test1", "sensor.test2"}) + assert metadata["sensor.test1"][1]["has_mean"] is True + assert metadata["sensor.test1"][1]["has_sum"] is False + assert metadata["sensor.test2"][1]["has_mean"] is True + assert metadata["sensor.test2"][1]["has_sum"] is False expected_1 = { "start": process_timestamp(zero).timestamp(), "end": process_timestamp(zero + timedelta(minutes=5)).timestamp(), @@ -99,8 +105,6 @@ def test_compile_hourly_statistics(hass_recorder: Callable[..., HomeAssistant]) "min": pytest.approx(10.0), "max": pytest.approx(20.0), "last_reset": None, - "state": None, - "sum": None, } expected_2 = { "start": process_timestamp(four).timestamp(), @@ -109,32 +113,44 @@ def test_compile_hourly_statistics(hass_recorder: Callable[..., HomeAssistant]) "min": pytest.approx(20.0), "max": pytest.approx(20.0), "last_reset": None, - "state": None, - "sum": None, } expected_stats1 = [expected_1, expected_2] expected_stats2 = [expected_1, expected_2] # Test statistics_during_period - stats = statistics_during_period(hass, zero, period="5minute") + stats = statistics_during_period( + hass, zero, period="5minute", statistic_ids={"sensor.test1", "sensor.test2"} + ) assert stats == {"sensor.test1": expected_stats1, "sensor.test2": expected_stats2} # Test statistics_during_period with a far future start and end date future = dt_util.as_utc(dt_util.parse_datetime("2221-11-01 00:00:00")) - stats = statistics_during_period(hass, future, end_time=future, period="5minute") + stats = statistics_during_period( + hass, + future, + end_time=future, + period="5minute", + statistic_ids={"sensor.test1", "sensor.test2"}, + ) assert stats == {} # Test statistics_during_period with a far future end date - stats = statistics_during_period(hass, zero, end_time=future, period="5minute") + stats = statistics_during_period( + hass, + zero, + end_time=future, + period="5minute", + statistic_ids={"sensor.test1", "sensor.test2"}, + ) assert stats == {"sensor.test1": expected_stats1, "sensor.test2": expected_stats2} stats = statistics_during_period( - hass, zero, statistic_ids=["sensor.test2"], period="5minute" + hass, zero, statistic_ids={"sensor.test2"}, period="5minute" ) assert stats == {"sensor.test2": expected_stats2} stats = statistics_during_period( - hass, zero, statistic_ids=["sensor.test3"], period="5minute" + hass, zero, statistic_ids={"sensor.test3"}, period="5minute" ) assert stats == {} @@ -567,15 +583,14 @@ async def test_import_statistics( import_fn(hass, external_metadata, (external_statistics1, external_statistics2)) await async_wait_recording_done(hass) - stats = statistics_during_period(hass, zero, period="hour") + stats = statistics_during_period( + hass, zero, period="hour", statistic_ids={statistic_id} + ) assert stats == { statistic_id: [ { "start": process_timestamp(period1).timestamp(), "end": process_timestamp(period1 + timedelta(hours=1)).timestamp(), - "max": None, - "mean": None, - "min": None, "last_reset": datetime_to_timestamp_or_none(last_reset_utc), "state": pytest.approx(0.0), "sum": pytest.approx(2.0), @@ -583,9 +598,6 @@ async def test_import_statistics( { "start": process_timestamp(period2).timestamp(), "end": process_timestamp(period2 + timedelta(hours=1)).timestamp(), - "max": None, - "mean": None, - "min": None, "last_reset": datetime_to_timestamp_or_none(last_reset_utc), "state": pytest.approx(1.0), "sum": pytest.approx(3.0), @@ -631,9 +643,6 @@ async def test_import_statistics( { "start": process_timestamp(period2).timestamp(), "end": process_timestamp(period2 + timedelta(hours=1)).timestamp(), - "max": None, - "mean": None, - "min": None, "last_reset": datetime_to_timestamp_or_none(last_reset_utc), "state": pytest.approx(1.0), "sum": pytest.approx(3.0), @@ -650,15 +659,14 @@ async def test_import_statistics( } import_fn(hass, external_metadata, (external_statistics,)) await async_wait_recording_done(hass) - stats = statistics_during_period(hass, zero, period="hour") + stats = statistics_during_period( + hass, zero, period="hour", statistic_ids={statistic_id} + ) assert stats == { statistic_id: [ { "start": process_timestamp(period1).timestamp(), "end": process_timestamp(period1 + timedelta(hours=1)).timestamp(), - "max": None, - "mean": None, - "min": None, "last_reset": None, "state": pytest.approx(5.0), "sum": pytest.approx(6.0), @@ -666,9 +674,6 @@ async def test_import_statistics( { "start": process_timestamp(period2).timestamp(), "end": process_timestamp(period2 + timedelta(hours=1)).timestamp(), - "max": None, - "mean": None, - "min": None, "last_reset": datetime_to_timestamp_or_none(last_reset_utc), "state": pytest.approx(1.0), "sum": pytest.approx(3.0), @@ -716,15 +721,14 @@ async def test_import_statistics( }, ) } - stats = statistics_during_period(hass, zero, period="hour") + stats = statistics_during_period( + hass, zero, period="hour", statistic_ids={statistic_id} + ) assert stats == { statistic_id: [ { "start": process_timestamp(period1).timestamp(), "end": process_timestamp(period1 + timedelta(hours=1)).timestamp(), - "max": pytest.approx(1.0), - "mean": pytest.approx(2.0), - "min": pytest.approx(3.0), "last_reset": datetime_to_timestamp_or_none(last_reset_utc), "state": pytest.approx(4.0), "sum": pytest.approx(5.0), @@ -732,9 +736,6 @@ async def test_import_statistics( { "start": process_timestamp(period2).timestamp(), "end": process_timestamp(period2 + timedelta(hours=1)).timestamp(), - "max": None, - "mean": None, - "min": None, "last_reset": datetime_to_timestamp_or_none(last_reset_utc), "state": pytest.approx(1.0), "sum": pytest.approx(3.0), @@ -757,15 +758,14 @@ async def test_import_statistics( assert response["success"] await async_wait_recording_done(hass) - stats = statistics_during_period(hass, zero, period="hour") + stats = statistics_during_period( + hass, zero, period="hour", statistic_ids={statistic_id} + ) assert stats == { statistic_id: [ { "start": process_timestamp(period1).timestamp(), "end": process_timestamp(period1 + timedelta(hours=1)).timestamp(), - "max": pytest.approx(1.0), - "mean": pytest.approx(2.0), - "min": pytest.approx(3.0), "last_reset": datetime_to_timestamp_or_none(last_reset_utc), "state": pytest.approx(4.0), "sum": pytest.approx(5.0), @@ -773,9 +773,6 @@ async def test_import_statistics( { "start": process_timestamp(period2).timestamp(), "end": process_timestamp(period2 + timedelta(hours=1)).timestamp(), - "max": None, - "mean": None, - "min": None, "last_reset": datetime_to_timestamp_or_none(last_reset_utc), "state": pytest.approx(1.0), "sum": pytest.approx(1000 * 1000 + 3.0), @@ -1020,7 +1017,9 @@ def test_weekly_statistics( async_add_external_statistics(hass, external_metadata, external_statistics) wait_recording_done(hass) - stats = statistics_during_period(hass, zero, period="week") + stats = statistics_during_period( + hass, zero, period="week", statistic_ids={"test:total_energy_import"} + ) week1_start = dt_util.as_utc(dt_util.parse_datetime("2022-10-03 00:00:00")) week1_end = dt_util.as_utc(dt_util.parse_datetime("2022-10-10 00:00:00")) week2_start = dt_util.as_utc(dt_util.parse_datetime("2022-10-10 00:00:00")) @@ -1030,9 +1029,6 @@ def test_weekly_statistics( { "start": week1_start.timestamp(), "end": week1_end.timestamp(), - "max": None, - "mean": None, - "min": None, "last_reset": None, "state": 1.0, "sum": 3.0, @@ -1040,9 +1036,6 @@ def test_weekly_statistics( { "start": week2_start.timestamp(), "end": week2_end.timestamp(), - "max": None, - "mean": None, - "min": None, "last_reset": None, "state": 3.0, "sum": 5.0, @@ -1061,9 +1054,6 @@ def test_weekly_statistics( { "start": week1_start.timestamp(), "end": week1_end.timestamp(), - "max": None, - "mean": None, - "min": None, "last_reset": None, "state": 1.0, "sum": 3.0, @@ -1071,9 +1061,6 @@ def test_weekly_statistics( { "start": week2_start.timestamp(), "end": week2_end.timestamp(), - "max": None, - "mean": None, - "min": None, "last_reset": None, "state": 3.0, "sum": 5.0, @@ -1158,7 +1145,9 @@ def test_monthly_statistics( async_add_external_statistics(hass, external_metadata, external_statistics) wait_recording_done(hass) - stats = statistics_during_period(hass, zero, period="month") + stats = statistics_during_period( + hass, zero, period="month", statistic_ids={"test:total_energy_import"} + ) sep_start = dt_util.as_utc(dt_util.parse_datetime("2021-09-01 00:00:00")) sep_end = dt_util.as_utc(dt_util.parse_datetime("2021-10-01 00:00:00")) oct_start = dt_util.as_utc(dt_util.parse_datetime("2021-10-01 00:00:00")) @@ -1168,9 +1157,6 @@ def test_monthly_statistics( { "start": sep_start.timestamp(), "end": sep_end.timestamp(), - "max": None, - "mean": None, - "min": None, "last_reset": None, "state": pytest.approx(1.0), "sum": pytest.approx(3.0), @@ -1178,9 +1164,6 @@ def test_monthly_statistics( { "start": oct_start.timestamp(), "end": oct_end.timestamp(), - "max": None, - "mean": None, - "min": None, "last_reset": None, "state": pytest.approx(3.0), "sum": pytest.approx(5.0), @@ -1203,9 +1186,6 @@ def test_monthly_statistics( { "start": sep_start.timestamp(), "end": sep_end.timestamp(), - "max": None, - "mean": None, - "min": None, "last_reset": None, "state": pytest.approx(1.0), "sum": pytest.approx(3.0), @@ -1213,9 +1193,6 @@ def test_monthly_statistics( { "start": oct_start.timestamp(), "end": oct_end.timestamp(), - "max": None, - "mean": None, - "min": None, "last_reset": None, "state": pytest.approx(3.0), "sum": pytest.approx(5.0), diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index 335bdafd643d..fffeb0198224 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -188,8 +188,6 @@ async def test_statistics_during_period( "min": pytest.approx(10), "max": pytest.approx(10), "last_reset": None, - "state": None, - "sum": None, } ] } @@ -955,8 +953,6 @@ async def test_statistics_during_period_unit_conversion( "min": pytest.approx(value), "max": pytest.approx(value), "last_reset": None, - "state": None, - "sum": None, } ] } @@ -983,8 +979,6 @@ async def test_statistics_during_period_unit_conversion( "min": pytest.approx(converted_value), "max": pytest.approx(converted_value), "last_reset": None, - "state": None, - "sum": None, } ] } @@ -1043,9 +1037,6 @@ async def test_sum_statistics_during_period_unit_conversion( { "start": int(now.timestamp() * 1000), "end": int((now + timedelta(minutes=5)).timestamp() * 1000), - "mean": None, - "min": None, - "max": None, "last_reset": None, "state": pytest.approx(value), "sum": pytest.approx(value), @@ -1071,9 +1062,6 @@ async def test_sum_statistics_during_period_unit_conversion( { "start": int(now.timestamp() * 1000), "end": int((now + timedelta(minutes=5)).timestamp() * 1000), - "mean": None, - "min": None, - "max": None, "last_reset": None, "state": pytest.approx(converted_value), "sum": pytest.approx(converted_value), @@ -1211,8 +1199,6 @@ async def test_statistics_during_period_in_the_past( "min": pytest.approx(10), "max": pytest.approx(10), "last_reset": None, - "state": None, - "sum": None, } ] } @@ -1238,8 +1224,6 @@ async def test_statistics_during_period_in_the_past( "min": pytest.approx(10), "max": pytest.approx(10), "last_reset": None, - "state": None, - "sum": None, } ] } @@ -1696,8 +1680,6 @@ async def test_clear_statistics( "min": pytest.approx(value), "max": pytest.approx(value), "last_reset": None, - "state": None, - "sum": None, } ], "sensor.test2": [ @@ -1708,8 +1690,6 @@ async def test_clear_statistics( "min": pytest.approx(value * 2), "max": pytest.approx(value * 2), "last_reset": None, - "state": None, - "sum": None, } ], "sensor.test3": [ @@ -1720,8 +1700,6 @@ async def test_clear_statistics( "min": pytest.approx(value * 3), "max": pytest.approx(value * 3), "last_reset": None, - "state": None, - "sum": None, } ], } @@ -1873,8 +1851,6 @@ async def test_update_statistics_metadata( "mean": 10.0, "min": 10.0, "start": int(now.timestamp() * 1000), - "state": None, - "sum": None, } ], } @@ -1937,8 +1913,6 @@ async def test_change_statistics_unit( "mean": 10.0, "min": 10.0, "start": int(now.timestamp() * 1000), - "state": None, - "sum": None, } ], } @@ -1993,8 +1967,6 @@ async def test_change_statistics_unit( "mean": 10000.0, "min": 10000.0, "start": int(now.timestamp() * 1000), - "state": None, - "sum": None, } ], } @@ -2036,8 +2008,6 @@ async def test_change_statistics_unit_errors( "mean": 10.0, "min": 10.0, "start": int(now.timestamp() * 1000), - "state": None, - "sum": None, } ], } @@ -2558,15 +2528,14 @@ async def test_import_statistics( assert response["result"] is None await async_wait_recording_done(hass) - stats = statistics_during_period(hass, zero, period="hour") + stats = statistics_during_period( + hass, zero, period="hour", statistic_ids={statistic_id} + ) assert stats == { statistic_id: [ { "start": period1.timestamp(), "end": (period1 + timedelta(hours=1)).timestamp(), - "max": None, - "mean": None, - "min": None, "last_reset": None, "state": pytest.approx(0.0), "sum": pytest.approx(2.0), @@ -2574,9 +2543,6 @@ async def test_import_statistics( { "start": period2.timestamp(), "end": (period2 + timedelta(hours=1)).timestamp(), - "max": None, - "mean": None, - "min": None, "last_reset": None, "state": pytest.approx(1.0), "sum": pytest.approx(3.0), @@ -2622,9 +2588,6 @@ async def test_import_statistics( { "start": period2.timestamp(), "end": (period2 + timedelta(hours=1)).timestamp(), - "max": None, - "mean": None, - "min": None, "last_reset": None, "state": pytest.approx(1.0), "sum": pytest.approx(3.0), @@ -2653,15 +2616,14 @@ async def test_import_statistics( assert response["result"] is None await async_wait_recording_done(hass) - stats = statistics_during_period(hass, zero, period="hour") + stats = statistics_during_period( + hass, zero, period="hour", statistic_ids={statistic_id} + ) assert stats == { statistic_id: [ { "start": period1.timestamp(), "end": (period1 + timedelta(hours=1)).timestamp(), - "max": None, - "mean": None, - "min": None, "last_reset": None, "state": pytest.approx(5.0), "sum": pytest.approx(6.0), @@ -2669,9 +2631,6 @@ async def test_import_statistics( { "start": period2.timestamp(), "end": (period2 + timedelta(hours=1)).timestamp(), - "max": None, - "mean": None, - "min": None, "last_reset": None, "state": pytest.approx(1.0), "sum": pytest.approx(3.0), @@ -2703,15 +2662,14 @@ async def test_import_statistics( assert response["result"] is None await async_wait_recording_done(hass) - stats = statistics_during_period(hass, zero, period="hour") + stats = statistics_during_period( + hass, zero, period="hour", statistic_ids={statistic_id} + ) assert stats == { statistic_id: [ { "start": period1.timestamp(), "end": (period1 + timedelta(hours=1)).timestamp(), - "max": pytest.approx(1.0), - "mean": pytest.approx(2.0), - "min": pytest.approx(3.0), "last_reset": None, "state": pytest.approx(4.0), "sum": pytest.approx(5.0), @@ -2719,9 +2677,6 @@ async def test_import_statistics( { "start": period2.timestamp(), "end": (period2 + timedelta(hours=1)).timestamp(), - "max": None, - "mean": None, - "min": None, "last_reset": None, "state": pytest.approx(1.0), "sum": pytest.approx(3.0), diff --git a/tests/components/tibber/test_statistics.py b/tests/components/tibber/test_statistics.py index 6de7549c285b..566e5a651a53 100644 --- a/tests/components/tibber/test_statistics.py +++ b/tests/components/tibber/test_statistics.py @@ -47,9 +47,6 @@ async def test_async_setup_entry(recorder_mock: Recorder, hass: HomeAssistant) - for k, stat in enumerate(stats[statistic_id]): assert stat["start"] == dt_util.parse_datetime(data[k]["from"]).timestamp() assert stat["state"] == data[k][key] - assert stat["mean"] is None - assert stat["min"] is None - assert stat["max"] is None assert stat["last_reset"] is None _sum += data[k][key] From 5cbf08f89af8351be2ea8f763df9f0e039744867 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 30 Apr 2023 21:32:14 +0200 Subject: [PATCH 111/822] Only store and pass around coordinator in Rituals Perfume Genie (#92298) --- .../rituals_perfume_genie/__init__.py | 27 ++++---- .../rituals_perfume_genie/binary_sensor.py | 23 +++---- .../components/rituals_perfume_genie/const.py | 3 - .../rituals_perfume_genie/coordinator.py | 8 +-- .../rituals_perfume_genie/entity.py | 15 ++--- .../rituals_perfume_genie/number.py | 28 ++++----- .../rituals_perfume_genie/select.py | 27 ++++---- .../rituals_perfume_genie/sensor.py | 63 +++++++++---------- .../rituals_perfume_genie/switch.py | 31 ++++----- .../rituals_perfume_genie/test_switch.py | 4 +- 10 files changed, 102 insertions(+), 127 deletions(-) diff --git a/homeassistant/components/rituals_perfume_genie/__init__.py b/homeassistant/components/rituals_perfume_genie/__init__.py index 7f079c003fc6..b607a84b5d15 100644 --- a/homeassistant/components/rituals_perfume_genie/__init__.py +++ b/homeassistant/components/rituals_perfume_genie/__init__.py @@ -1,4 +1,6 @@ """The Rituals Perfume Genie integration.""" +import asyncio + import aiohttp from pyrituals import Account @@ -8,7 +10,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import ACCOUNT_HASH, COORDINATORS, DEVICES, DOMAIN +from .const import ACCOUNT_HASH, DOMAIN from .coordinator import RitualsDataUpdateCoordinator PLATFORMS = [ @@ -30,20 +32,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except aiohttp.ClientError as err: raise ConfigEntryNotReady from err - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { - COORDINATORS: {}, - DEVICES: {}, + # Create a coordinator for each diffuser + coordinators = { + diffuser.hublot: RitualsDataUpdateCoordinator(hass, diffuser) + for diffuser in account_devices } - for device in account_devices: - hublot = device.hublot - - coordinator = RitualsDataUpdateCoordinator(hass, device) - await coordinator.async_config_entry_first_refresh() - - hass.data[DOMAIN][entry.entry_id][DEVICES][hublot] = device - hass.data[DOMAIN][entry.entry_id][COORDINATORS][hublot] = coordinator + # Refresh all coordinators + await asyncio.gather( + *[ + coordinator.async_config_entry_first_refresh() + for coordinator in coordinators.values() + ] + ) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinators await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/rituals_perfume_genie/binary_sensor.py b/homeassistant/components/rituals_perfume_genie/binary_sensor.py index 05eac9377ab8..95d28c9797b9 100644 --- a/homeassistant/components/rituals_perfume_genie/binary_sensor.py +++ b/homeassistant/components/rituals_perfume_genie/binary_sensor.py @@ -1,8 +1,6 @@ """Support for Rituals Perfume Genie binary sensors.""" from __future__ import annotations -from pyrituals import Diffuser - from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, @@ -12,7 +10,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import COORDINATORS, DEVICES, DOMAIN +from .const import DOMAIN from .coordinator import RitualsDataUpdateCoordinator from .entity import DiffuserEntity @@ -25,13 +23,14 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the diffuser binary sensors.""" - diffusers = hass.data[DOMAIN][config_entry.entry_id][DEVICES] - coordinators = hass.data[DOMAIN][config_entry.entry_id][COORDINATORS] + coordinators: dict[str, RitualsDataUpdateCoordinator] = hass.data[DOMAIN][ + config_entry.entry_id + ] async_add_entities( - DiffuserBatteryChargingBinarySensor(diffuser, coordinators[hublot]) - for hublot, diffuser in diffusers.items() - if diffuser.has_battery + DiffuserBatteryChargingBinarySensor(coordinator) + for coordinator in coordinators.values() + if coordinator.diffuser.has_battery ) @@ -41,13 +40,11 @@ class DiffuserBatteryChargingBinarySensor(DiffuserEntity, BinarySensorEntity): _attr_device_class = BinarySensorDeviceClass.BATTERY_CHARGING _attr_entity_category = EntityCategory.DIAGNOSTIC - def __init__( - self, diffuser: Diffuser, coordinator: RitualsDataUpdateCoordinator - ) -> None: + def __init__(self, coordinator: RitualsDataUpdateCoordinator) -> None: """Initialize the battery charging binary sensor.""" - super().__init__(diffuser, coordinator, CHARGING_SUFFIX) + super().__init__(coordinator, CHARGING_SUFFIX) @property def is_on(self) -> bool: """Return the state of the battery charging binary sensor.""" - return self._diffuser.charging + return self.coordinator.diffuser.charging diff --git a/homeassistant/components/rituals_perfume_genie/const.py b/homeassistant/components/rituals_perfume_genie/const.py index 05c034196201..35d1c32d3063 100644 --- a/homeassistant/components/rituals_perfume_genie/const.py +++ b/homeassistant/components/rituals_perfume_genie/const.py @@ -6,7 +6,4 @@ DOMAIN = "rituals_perfume_genie" ACCOUNT_HASH = "account_hash" -COORDINATORS = "coordinators" -DEVICES = "devices" - UPDATE_INTERVAL = timedelta(minutes=2) diff --git a/homeassistant/components/rituals_perfume_genie/coordinator.py b/homeassistant/components/rituals_perfume_genie/coordinator.py index ac6c252f4d27..b63b28e4de9d 100644 --- a/homeassistant/components/rituals_perfume_genie/coordinator.py +++ b/homeassistant/components/rituals_perfume_genie/coordinator.py @@ -14,16 +14,16 @@ _LOGGER = logging.getLogger(__name__) class RitualsDataUpdateCoordinator(DataUpdateCoordinator[None]): """Class to manage fetching Rituals Perfume Genie device data from single endpoint.""" - def __init__(self, hass: HomeAssistant, device: Diffuser) -> None: + def __init__(self, hass: HomeAssistant, diffuser: Diffuser) -> None: """Initialize global Rituals Perfume Genie data updater.""" - self._device = device + self.diffuser = diffuser super().__init__( hass, _LOGGER, - name=f"{DOMAIN}-{device.hublot}", + name=f"{DOMAIN}-{diffuser.hublot}", update_interval=UPDATE_INTERVAL, ) async def _async_update_data(self) -> None: """Fetch data from Rituals.""" - await self._device.update_data() + await self.diffuser.update_data() diff --git a/homeassistant/components/rituals_perfume_genie/entity.py b/homeassistant/components/rituals_perfume_genie/entity.py index fb049842c07a..b5e016c20661 100644 --- a/homeassistant/components/rituals_perfume_genie/entity.py +++ b/homeassistant/components/rituals_perfume_genie/entity.py @@ -1,8 +1,6 @@ """Base class for Rituals Perfume Genie diffuser entity.""" from __future__ import annotations -from pyrituals import Diffuser - from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -19,28 +17,25 @@ class DiffuserEntity(CoordinatorEntity[RitualsDataUpdateCoordinator]): def __init__( self, - diffuser: Diffuser, coordinator: RitualsDataUpdateCoordinator, entity_suffix: str, ) -> None: """Init from config, hookup diffuser and coordinator.""" super().__init__(coordinator) - self._diffuser = diffuser - - hublot = self._diffuser.hublot - hubname = self._diffuser.name + hublot = coordinator.diffuser.hublot + hubname = coordinator.diffuser.name self._attr_name = f"{hubname}{entity_suffix}" self._attr_unique_id = f"{hublot}{entity_suffix}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, hublot)}, manufacturer=MANUFACTURER, - model=MODEL if diffuser.has_battery else MODEL2, + model=MODEL if coordinator.diffuser.has_battery else MODEL2, name=hubname, - sw_version=diffuser.version, + sw_version=coordinator.diffuser.version, ) @property def available(self) -> bool: """Return if the entity is available.""" - return super().available and self._diffuser.is_online + return super().available and self.coordinator.diffuser.is_online diff --git a/homeassistant/components/rituals_perfume_genie/number.py b/homeassistant/components/rituals_perfume_genie/number.py index 232621ff5765..3da28f8b6f42 100644 --- a/homeassistant/components/rituals_perfume_genie/number.py +++ b/homeassistant/components/rituals_perfume_genie/number.py @@ -1,14 +1,12 @@ """Support for Rituals Perfume Genie numbers.""" from __future__ import annotations -from pyrituals import Diffuser - from homeassistant.components.number import NumberEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import COORDINATORS, DEVICES, DOMAIN +from .const import DOMAIN from .coordinator import RitualsDataUpdateCoordinator from .entity import DiffuserEntity @@ -24,14 +22,12 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the diffuser numbers.""" - diffusers = hass.data[DOMAIN][config_entry.entry_id][DEVICES] - coordinators = hass.data[DOMAIN][config_entry.entry_id][COORDINATORS] - entities: list[DiffuserEntity] = [] - for hublot, diffuser in diffusers.items(): - coordinator = coordinators[hublot] - entities.append(DiffuserPerfumeAmount(diffuser, coordinator)) - - async_add_entities(entities) + coordinators: dict[str, RitualsDataUpdateCoordinator] = hass.data[DOMAIN][ + config_entry.entry_id + ] + async_add_entities( + DiffuserPerfumeAmount(coordinator) for coordinator in coordinators.values() + ) class DiffuserPerfumeAmount(DiffuserEntity, NumberEntity): @@ -41,16 +37,14 @@ class DiffuserPerfumeAmount(DiffuserEntity, NumberEntity): _attr_native_max_value = MAX_PERFUME_AMOUNT _attr_native_min_value = MIN_PERFUME_AMOUNT - def __init__( - self, diffuser: Diffuser, coordinator: RitualsDataUpdateCoordinator - ) -> None: + def __init__(self, coordinator: RitualsDataUpdateCoordinator) -> None: """Initialize the diffuser perfume amount number.""" - super().__init__(diffuser, coordinator, PERFUME_AMOUNT_SUFFIX) + super().__init__(coordinator, PERFUME_AMOUNT_SUFFIX) @property def native_value(self) -> int: """Return the current perfume amount.""" - return self._diffuser.perfume_amount + return self.coordinator.diffuser.perfume_amount async def async_set_native_value(self, value: float) -> None: """Set the perfume amount.""" @@ -59,4 +53,4 @@ class DiffuserPerfumeAmount(DiffuserEntity, NumberEntity): f"Can't set the perfume amount to {value}. Perfume amount must be an" " integer." ) - await self._diffuser.set_perfume_amount(int(value)) + await self.coordinator.diffuser.set_perfume_amount(int(value)) diff --git a/homeassistant/components/rituals_perfume_genie/select.py b/homeassistant/components/rituals_perfume_genie/select.py index b7119e6d9473..b40c30455777 100644 --- a/homeassistant/components/rituals_perfume_genie/select.py +++ b/homeassistant/components/rituals_perfume_genie/select.py @@ -1,15 +1,13 @@ """Support for Rituals Perfume Genie numbers.""" from __future__ import annotations -from pyrituals import Diffuser - from homeassistant.components.select import SelectEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import AREA_SQUARE_METERS, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import COORDINATORS, DEVICES, DOMAIN +from .const import DOMAIN from .coordinator import RitualsDataUpdateCoordinator from .entity import DiffuserEntity @@ -22,11 +20,12 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the diffuser select entities.""" - diffusers = hass.data[DOMAIN][config_entry.entry_id][DEVICES] - coordinators = hass.data[DOMAIN][config_entry.entry_id][COORDINATORS] + coordinators: dict[str, RitualsDataUpdateCoordinator] = hass.data[DOMAIN][ + config_entry.entry_id + ] + async_add_entities( - DiffuserRoomSize(diffuser, coordinators[hublot]) - for hublot, diffuser in diffusers.items() + DiffuserRoomSize(coordinator) for coordinator in coordinators.values() ) @@ -38,18 +37,18 @@ class DiffuserRoomSize(DiffuserEntity, SelectEntity): _attr_options = ["15", "30", "60", "100"] _attr_entity_category = EntityCategory.CONFIG - def __init__( - self, diffuser: Diffuser, coordinator: RitualsDataUpdateCoordinator - ) -> None: + def __init__(self, coordinator: RitualsDataUpdateCoordinator) -> None: """Initialize the diffuser room size select entity.""" - super().__init__(diffuser, coordinator, ROOM_SIZE_SUFFIX) - self._attr_entity_registry_enabled_default = diffuser.has_battery + super().__init__(coordinator, ROOM_SIZE_SUFFIX) + self._attr_entity_registry_enabled_default = ( + self.coordinator.diffuser.has_battery + ) @property def current_option(self) -> str: """Return the diffuser room size.""" - return str(self._diffuser.room_size_square_meter) + return str(self.coordinator.diffuser.room_size_square_meter) async def async_select_option(self, option: str) -> None: """Change the diffuser room size.""" - await self._diffuser.set_room_size_square_meter(int(option)) + await self.coordinator.diffuser.set_room_size_square_meter(int(option)) diff --git a/homeassistant/components/rituals_perfume_genie/sensor.py b/homeassistant/components/rituals_perfume_genie/sensor.py index fa6f75b288f0..1f301d3538d9 100644 --- a/homeassistant/components/rituals_perfume_genie/sensor.py +++ b/homeassistant/components/rituals_perfume_genie/sensor.py @@ -1,15 +1,13 @@ """Support for Rituals Perfume Genie sensors.""" from __future__ import annotations -from pyrituals import Diffuser - from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import COORDINATORS, DEVICES, DOMAIN +from .const import DOMAIN from .coordinator import RitualsDataUpdateCoordinator from .entity import DiffuserEntity @@ -25,16 +23,21 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the diffuser sensors.""" - diffusers = hass.data[DOMAIN][config_entry.entry_id][DEVICES] - coordinators = hass.data[DOMAIN][config_entry.entry_id][COORDINATORS] + coordinators: dict[str, RitualsDataUpdateCoordinator] = hass.data[DOMAIN][ + config_entry.entry_id + ] + entities: list[DiffuserEntity] = [] - for hublot, diffuser in diffusers.items(): - coordinator = coordinators[hublot] - entities.append(DiffuserPerfumeSensor(diffuser, coordinator)) - entities.append(DiffuserFillSensor(diffuser, coordinator)) - entities.append(DiffuserWifiSensor(diffuser, coordinator)) - if diffuser.has_battery: - entities.append(DiffuserBatterySensor(diffuser, coordinator)) + for coordinator in coordinators.values(): + entities.extend( + [ + DiffuserPerfumeSensor(coordinator), + DiffuserFillSensor(coordinator), + DiffuserWifiSensor(coordinator), + ] + ) + if coordinator.diffuser.has_battery: + entities.append(DiffuserBatterySensor(coordinator)) async_add_entities(entities) @@ -42,45 +45,41 @@ async def async_setup_entry( class DiffuserPerfumeSensor(DiffuserEntity, SensorEntity): """Representation of a diffuser perfume sensor.""" - def __init__( - self, diffuser: Diffuser, coordinator: RitualsDataUpdateCoordinator - ) -> None: + def __init__(self, coordinator: RitualsDataUpdateCoordinator) -> None: """Initialize the perfume sensor.""" - super().__init__(diffuser, coordinator, PERFUME_SUFFIX) + super().__init__(coordinator, PERFUME_SUFFIX) @property def icon(self) -> str: """Return the perfume sensor icon.""" - if self._diffuser.has_cartridge: + if self.coordinator.diffuser.has_cartridge: return "mdi:tag-text" return "mdi:tag-remove" @property def native_value(self) -> str: """Return the state of the perfume sensor.""" - return self._diffuser.perfume + return self.coordinator.diffuser.perfume class DiffuserFillSensor(DiffuserEntity, SensorEntity): """Representation of a diffuser fill sensor.""" - def __init__( - self, diffuser: Diffuser, coordinator: RitualsDataUpdateCoordinator - ) -> None: + def __init__(self, coordinator: RitualsDataUpdateCoordinator) -> None: """Initialize the fill sensor.""" - super().__init__(diffuser, coordinator, FILL_SUFFIX) + super().__init__(coordinator, FILL_SUFFIX) @property def icon(self) -> str: """Return the fill sensor icon.""" - if self._diffuser.has_cartridge: + if self.coordinator.diffuser.has_cartridge: return "mdi:beaker" return "mdi:beaker-question" @property def native_value(self) -> str: """Return the state of the fill sensor.""" - return self._diffuser.fill + return self.coordinator.diffuser.fill class DiffuserBatterySensor(DiffuserEntity, SensorEntity): @@ -90,16 +89,14 @@ class DiffuserBatterySensor(DiffuserEntity, SensorEntity): _attr_native_unit_of_measurement = PERCENTAGE _attr_entity_category = EntityCategory.DIAGNOSTIC - def __init__( - self, diffuser: Diffuser, coordinator: RitualsDataUpdateCoordinator - ) -> None: + def __init__(self, coordinator: RitualsDataUpdateCoordinator) -> None: """Initialize the battery sensor.""" - super().__init__(diffuser, coordinator, BATTERY_SUFFIX) + super().__init__(coordinator, BATTERY_SUFFIX) @property def native_value(self) -> int: """Return the state of the battery sensor.""" - return self._diffuser.battery_percentage + return self.coordinator.diffuser.battery_percentage class DiffuserWifiSensor(DiffuserEntity, SensorEntity): @@ -108,13 +105,11 @@ class DiffuserWifiSensor(DiffuserEntity, SensorEntity): _attr_native_unit_of_measurement = PERCENTAGE _attr_entity_category = EntityCategory.DIAGNOSTIC - def __init__( - self, diffuser: Diffuser, coordinator: RitualsDataUpdateCoordinator - ) -> None: + def __init__(self, coordinator: RitualsDataUpdateCoordinator) -> None: """Initialize the wifi sensor.""" - super().__init__(diffuser, coordinator, WIFI_SUFFIX) + super().__init__(coordinator, WIFI_SUFFIX) @property def native_value(self) -> int: """Return the state of the wifi sensor.""" - return self._diffuser.wifi_percentage + return self.coordinator.diffuser.wifi_percentage diff --git a/homeassistant/components/rituals_perfume_genie/switch.py b/homeassistant/components/rituals_perfume_genie/switch.py index 3c43a960a2d1..92513d9a83b5 100644 --- a/homeassistant/components/rituals_perfume_genie/switch.py +++ b/homeassistant/components/rituals_perfume_genie/switch.py @@ -3,14 +3,12 @@ from __future__ import annotations from typing import Any -from pyrituals import Diffuser - from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import COORDINATORS, DEVICES, DOMAIN +from .const import DOMAIN from .coordinator import RitualsDataUpdateCoordinator from .entity import DiffuserEntity @@ -21,14 +19,13 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the diffuser switch.""" - diffusers = hass.data[DOMAIN][config_entry.entry_id][DEVICES] - coordinators = hass.data[DOMAIN][config_entry.entry_id][COORDINATORS] - entities = [] - for hublot, diffuser in diffusers.items(): - coordinator = coordinators[hublot] - entities.append(DiffuserSwitch(diffuser, coordinator)) + coordinators: dict[str, RitualsDataUpdateCoordinator] = hass.data[DOMAIN][ + config_entry.entry_id + ] - async_add_entities(entities) + async_add_entities( + DiffuserSwitch(coordinator) for coordinator in coordinators.values() + ) class DiffuserSwitch(DiffuserEntity, SwitchEntity): @@ -36,27 +33,25 @@ class DiffuserSwitch(DiffuserEntity, SwitchEntity): _attr_icon = "mdi:fan" - def __init__( - self, diffuser: Diffuser, coordinator: RitualsDataUpdateCoordinator - ) -> None: + def __init__(self, coordinator: RitualsDataUpdateCoordinator) -> None: """Initialize the diffuser switch.""" - super().__init__(diffuser, coordinator, "") - self._attr_is_on = self._diffuser.is_on + super().__init__(coordinator, "") + self._attr_is_on = self.coordinator.diffuser.is_on async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" - await self._diffuser.turn_on() + await self.coordinator.diffuser.turn_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._diffuser.turn_off() + await self.coordinator.diffuser.turn_off() self._attr_is_on = False self.async_write_ha_state() @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" - self._attr_is_on = self._diffuser.is_on + self._attr_is_on = self.coordinator.diffuser.is_on self.async_write_ha_state() diff --git a/tests/components/rituals_perfume_genie/test_switch.py b/tests/components/rituals_perfume_genie/test_switch.py index cb688f528f97..7f33e2381181 100644 --- a/tests/components/rituals_perfume_genie/test_switch.py +++ b/tests/components/rituals_perfume_genie/test_switch.py @@ -2,7 +2,7 @@ from __future__ import annotations from homeassistant.components.homeassistant import SERVICE_UPDATE_ENTITY -from homeassistant.components.rituals_perfume_genie.const import COORDINATORS, DOMAIN +from homeassistant.components.rituals_perfume_genie.const import DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, @@ -47,7 +47,7 @@ async def test_switch_handle_coordinator_update(hass: HomeAssistant) -> None: diffuser = mock_diffuser_v1_battery_cartridge() await init_integration(hass, config_entry, [diffuser]) await async_setup_component(hass, "homeassistant", {}) - coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATORS]["lot123v1"] + coordinator = hass.data[DOMAIN][config_entry.entry_id]["lot123v1"] diffuser.is_on = False state = hass.states.get("switch.genie") From 6f63ed07f9b3f12514df288473fda814a6dc74bb Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 30 Apr 2023 12:56:29 -0700 Subject: [PATCH 112/822] Deprecate legacy Works With Nest (again) (#92304) --- homeassistant/components/nest/legacy/__init__.py | 15 ++++++++++++++- homeassistant/components/nest/strings.json | 4 ++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/nest/legacy/__init__.py b/homeassistant/components/nest/legacy/__init__.py index 3c3a10a61bfe..88d046fb62be 100644 --- a/homeassistant/components/nest/legacy/__init__.py +++ b/homeassistant/components/nest/legacy/__init__.py @@ -21,7 +21,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send from homeassistant.helpers.entity import DeviceInfo, Entity @@ -108,6 +108,19 @@ async def async_setup_legacy(hass: HomeAssistant, config: dict) -> bool: if DOMAIN not in config: return True + ir.async_create_issue( + hass, + DOMAIN, + "legacy_nest_deprecated", + breaks_in_ha_version="2023.8.0", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="legacy_nest_deprecated", + translation_placeholders={ + "documentation_url": "https://www.home-assistant.io/integrations/nest/", + }, + ) + conf = config[DOMAIN] local_auth.initialize(hass, conf[CONF_CLIENT_ID], conf[CONF_CLIENT_SECRET]) diff --git a/homeassistant/components/nest/strings.json b/homeassistant/components/nest/strings.json index 4176ad1e2279..05ccae00d057 100644 --- a/homeassistant/components/nest/strings.json +++ b/homeassistant/components/nest/strings.json @@ -97,6 +97,10 @@ "removed_app_auth": { "title": "Nest Authentication Credentials must be updated", "description": "To improve security and reduce phishing risk Google has deprecated the authentication method used by Home Assistant.\n\n**This requires action by you to resolve** ([more info]({more_info_url}))\n\n1. Visit the integrations page\n1. Click Reconfigure on the Nest integration.\n1. Home Assistant will walk you through the steps to upgrade to Web Authentication.\n\nSee the Nest [integration instructions]({documentation_url}) for troubleshooting information." + }, + "legacy_nest_deprecated": { + "title": "Legacy Works With Nest is being removed", + "description": "Legacy Works With Nest is being removed from Home Assistant.\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." } }, "entity": { From c0d0c89293df8e15b4288e0a4064291f77495aca Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Sun, 30 Apr 2023 16:33:51 -0400 Subject: [PATCH 113/822] Add `time` platform (#81949) --- CODEOWNERS | 2 + homeassistant/components/demo/__init__.py | 1 + homeassistant/components/demo/time.py | 63 ++++++++++ homeassistant/components/time/__init__.py | 109 ++++++++++++++++++ homeassistant/components/time/const.py | 5 + homeassistant/components/time/manifest.json | 8 ++ homeassistant/components/time/services.yaml | 14 +++ homeassistant/components/time/strings.json | 8 ++ homeassistant/const.py | 1 + tests/components/demo/test_time.py | 34 ++++++ tests/components/time/__init__.py | 1 + tests/components/time/test_init.py | 52 +++++++++ .../custom_components/test/time.py | 50 ++++++++ 13 files changed, 348 insertions(+) create mode 100644 homeassistant/components/demo/time.py create mode 100644 homeassistant/components/time/__init__.py create mode 100644 homeassistant/components/time/const.py create mode 100644 homeassistant/components/time/manifest.json create mode 100644 homeassistant/components/time/services.yaml create mode 100644 homeassistant/components/time/strings.json create mode 100644 tests/components/demo/test_time.py create mode 100644 tests/components/time/__init__.py create mode 100644 tests/components/time/test_init.py create mode 100644 tests/testing_config/custom_components/test/time.py diff --git a/CODEOWNERS b/CODEOWNERS index 11cdf013aae1..ca0475f25eed 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1236,6 +1236,8 @@ build.json @home-assistant/supervisor /tests/components/tile/ @bachya /homeassistant/components/tilt_ble/ @apt-itude /tests/components/tilt_ble/ @apt-itude +/homeassistant/components/time/ @home-assistant/core +/tests/components/time/ @home-assistant/core /homeassistant/components/time_date/ @fabaff /tests/components/time_date/ @fabaff /homeassistant/components/tmb/ @alemuro diff --git a/homeassistant/components/demo/__init__.py b/homeassistant/components/demo/__init__.py index 45ec00e48466..85f7b89483f3 100644 --- a/homeassistant/components/demo/__init__.py +++ b/homeassistant/components/demo/__init__.py @@ -40,6 +40,7 @@ COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM = [ Platform.STT, Platform.SWITCH, Platform.TEXT, + Platform.TIME, Platform.UPDATE, Platform.VACUUM, Platform.WATER_HEATER, diff --git a/homeassistant/components/demo/time.py b/homeassistant/components/demo/time.py new file mode 100644 index 000000000000..aafd425a024f --- /dev/null +++ b/homeassistant/components/demo/time.py @@ -0,0 +1,63 @@ +"""Demo platform that offers a fake time entity.""" +from __future__ import annotations + +from datetime import time + +from homeassistant.components.time import TimeEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import DEVICE_DEFAULT_NAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType + +from . import DOMAIN + + +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up the Demo time entity.""" + async_add_entities([DemoTime("time", "Time", time(12, 0, 0), "mdi:clock", False)]) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Demo config entry.""" + await async_setup_platform(hass, {}, async_add_entities) + + +class DemoTime(TimeEntity): + """Representation of a Demo time entity.""" + + _attr_should_poll = False + + def __init__( + self, + unique_id: str, + name: str, + state: time, + icon: str, + assumed_state: bool, + ) -> None: + """Initialize the Demo time entity.""" + self._attr_assumed_state = assumed_state + self._attr_icon = icon + self._attr_name = name or DEVICE_DEFAULT_NAME + self._attr_native_value = state + self._attr_unique_id = unique_id + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, name=self.name + ) + + async def async_set_value(self, value: time) -> None: + """Update the time.""" + self._attr_native_value = value + self.async_write_ha_state() diff --git a/homeassistant/components/time/__init__.py b/homeassistant/components/time/__init__.py new file mode 100644 index 000000000000..26d40191fb95 --- /dev/null +++ b/homeassistant/components/time/__init__.py @@ -0,0 +1,109 @@ +"""Component to allow setting time as platforms.""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import time, timedelta +import logging +from typing import final + +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TIME +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.config_validation import ( # noqa: F401 + PLATFORM_SCHEMA, + PLATFORM_SCHEMA_BASE, +) +from homeassistant.helpers.entity import Entity, EntityDescription +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.typing import ConfigType + +from .const import DOMAIN, SERVICE_SET_VALUE + +SCAN_INTERVAL = timedelta(seconds=30) + +ENTITY_ID_FORMAT = DOMAIN + ".{}" + +_LOGGER = logging.getLogger(__name__) + +__all__ = ["DOMAIN", "TimeEntity", "TimeEntityDescription"] + + +async def _async_set_value(entity: TimeEntity, service_call: ServiceCall) -> None: + """Service call wrapper to set a new date.""" + return await entity.async_set_value(service_call.data[ATTR_TIME]) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up Time entities.""" + component = hass.data[DOMAIN] = EntityComponent[TimeEntity]( + _LOGGER, DOMAIN, hass, SCAN_INTERVAL + ) + await component.async_setup(config) + + component.async_register_entity_service( + SERVICE_SET_VALUE, {vol.Required(ATTR_TIME): cv.time}, _async_set_value + ) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a config entry.""" + component: EntityComponent[TimeEntity] = hass.data[DOMAIN] + return await component.async_setup_entry(entry) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + component: EntityComponent[TimeEntity] = hass.data[DOMAIN] + return await component.async_unload_entry(entry) + + +@dataclass +class TimeEntityDescription(EntityDescription): + """A class that describes time entities.""" + + +class TimeEntity(Entity): + """Representation of a Time entity.""" + + entity_description: TimeEntityDescription + _attr_native_value: time | None + _attr_device_class: None = None + _attr_state: None = None + + @property + @final + def device_class(self) -> None: + """Return the device class for the entity.""" + return None + + @property + @final + def state_attributes(self) -> None: + """Return the state attributes.""" + return None + + @property + @final + def state(self) -> str | None: + """Return the entity state.""" + if self.native_value is None: + return None + return self.native_value.isoformat() + + @property + def native_value(self) -> time | None: + """Return the value reported by the time.""" + return self._attr_native_value + + def set_value(self, value: time) -> None: + """Change the time.""" + raise NotImplementedError() + + async def async_set_value(self, value: time) -> None: + """Change the time.""" + await self.hass.async_add_executor_job(self.set_value, value) diff --git a/homeassistant/components/time/const.py b/homeassistant/components/time/const.py new file mode 100644 index 000000000000..23901915c9f2 --- /dev/null +++ b/homeassistant/components/time/const.py @@ -0,0 +1,5 @@ +"""Provides the constants needed for the component.""" + +DOMAIN = "time" + +SERVICE_SET_VALUE = "set_value" diff --git a/homeassistant/components/time/manifest.json b/homeassistant/components/time/manifest.json new file mode 100644 index 000000000000..f3c243e0005a --- /dev/null +++ b/homeassistant/components/time/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "time", + "name": "Time", + "codeowners": ["@home-assistant/core"], + "documentation": "https://www.home-assistant.io/integrations/time", + "integration_type": "entity", + "quality_scale": "internal" +} diff --git a/homeassistant/components/time/services.yaml b/homeassistant/components/time/services.yaml new file mode 100644 index 000000000000..a8c843ab55ac --- /dev/null +++ b/homeassistant/components/time/services.yaml @@ -0,0 +1,14 @@ +set_value: + name: Set Time + description: Set the time for a time entity. + target: + entity: + domain: time + fields: + time: + name: Time + description: The time to set. + required: true + example: "22:15" + selector: + time: diff --git a/homeassistant/components/time/strings.json b/homeassistant/components/time/strings.json new file mode 100644 index 000000000000..e8d92a30e2ea --- /dev/null +++ b/homeassistant/components/time/strings.json @@ -0,0 +1,8 @@ +{ + "title": "Time", + "entity_component": { + "_": { + "name": "[%key:component::time::title%]" + } + } +} diff --git a/homeassistant/const.py b/homeassistant/const.py index ad6a25760990..46fc8b57568b 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -51,6 +51,7 @@ class Platform(StrEnum): STT = "stt" SWITCH = "switch" TEXT = "text" + TIME = "time" TTS = "tts" VACUUM = "vacuum" UPDATE = "update" diff --git a/tests/components/demo/test_time.py b/tests/components/demo/test_time.py new file mode 100644 index 000000000000..555cfe3ffc96 --- /dev/null +++ b/tests/components/demo/test_time.py @@ -0,0 +1,34 @@ +"""The tests for the demo time component.""" +import pytest + +from homeassistant.components.time import ATTR_TIME, DOMAIN, SERVICE_SET_VALUE +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +ENTITY_TIME = "time.time" + + +@pytest.fixture(autouse=True) +async def setup_demo_datetime(hass: HomeAssistant) -> None: + """Initialize setup demo time.""" + assert await async_setup_component(hass, DOMAIN, {"time": {"platform": "demo"}}) + await hass.async_block_till_done() + + +def test_setup_params(hass: HomeAssistant) -> None: + """Test the initial parameters.""" + state = hass.states.get(ENTITY_TIME) + assert state.state == "12:00:00" + + +async def test_set_value(hass: HomeAssistant) -> None: + """Test set value service.""" + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: ENTITY_TIME, ATTR_TIME: "01:02:03"}, + blocking=True, + ) + state = hass.states.get(ENTITY_TIME) + assert state.state == "01:02:03" diff --git a/tests/components/time/__init__.py b/tests/components/time/__init__.py new file mode 100644 index 000000000000..dc24f4e53288 --- /dev/null +++ b/tests/components/time/__init__.py @@ -0,0 +1 @@ +"""Tests for the time component.""" diff --git a/tests/components/time/test_init.py b/tests/components/time/test_init.py new file mode 100644 index 000000000000..a3248c96361c --- /dev/null +++ b/tests/components/time/test_init.py @@ -0,0 +1,52 @@ +"""The tests for the time component.""" +from datetime import time + +from homeassistant.components.time import DOMAIN, SERVICE_SET_VALUE, TimeEntity +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_FRIENDLY_NAME, + ATTR_TIME, + CONF_PLATFORM, +) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + + +class MockTimeEntity(TimeEntity): + """Mock time device to use in tests.""" + + def __init__(self, native_value=time(12, 0, 0)) -> None: + """Initialize mock time entity.""" + self._attr_native_value = native_value + + async def async_set_value(self, value: time) -> None: + """Set the value of the time.""" + self._attr_native_value = value + + +async def test_date(hass: HomeAssistant, enable_custom_integrations: None) -> None: + """Test time entity.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() + + state = hass.states.get("time.test") + assert state.state == "01:02:03" + assert state.attributes == {ATTR_FRIENDLY_NAME: "test"} + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + {ATTR_TIME: time(2, 3, 4), ATTR_ENTITY_ID: "time.test"}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("time.test") + assert state.state == "02:03:04" + + date_entity = MockTimeEntity(native_value=None) + assert date_entity.state is None + assert date_entity.state_attributes is None diff --git a/tests/testing_config/custom_components/test/time.py b/tests/testing_config/custom_components/test/time.py new file mode 100644 index 000000000000..9c2f991d694b --- /dev/null +++ b/tests/testing_config/custom_components/test/time.py @@ -0,0 +1,50 @@ +"""Provide a mock time platform. + +Call init before using it in your tests to ensure clean test data. +""" +from datetime import time + +from homeassistant.components.time import TimeEntity + +from tests.common import MockEntity + +UNIQUE_TIME = "unique_time" + +ENTITIES = [] + + +class MockTimeEntity(MockEntity, TimeEntity): + """Mock time class.""" + + @property + def native_value(self): + """Return the native value of this time.""" + return self._handle("native_value") + + def set_value(self, value: time) -> None: + """Change the time.""" + self._values["native_value"] = value + + +def init(empty=False): + """Initialize the platform with entities.""" + global ENTITIES + + ENTITIES = ( + [] + if empty + else [ + MockTimeEntity( + name="test", + unique_id=UNIQUE_TIME, + native_value=time(1, 2, 3), + ), + ] + ) + + +async def async_setup_platform( + hass, config, async_add_entities_callback, discovery_info=None +): + """Return mock entities.""" + async_add_entities_callback(ENTITIES) From e7433c42b9575519d4dd1aaddc9da09efad2977c Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 30 Apr 2023 18:00:40 -0700 Subject: [PATCH 114/822] Cleanup code from nest yaml migration and OOB auth deprecation (#92311) --- homeassistant/components/nest/__init__.py | 85 +------- homeassistant/components/nest/config_flow.py | 69 ------- homeassistant/components/nest/strings.json | 16 -- tests/components/nest/common.py | 75 +------- tests/components/nest/conftest.py | 45 +++-- tests/components/nest/test_api.py | 62 +++--- tests/components/nest/test_config_flow_sdm.py | 181 ++---------------- tests/components/nest/test_init_sdm.py | 38 +--- 8 files changed, 79 insertions(+), 492 deletions(-) diff --git a/homeassistant/components/nest/__init__.py b/homeassistant/components/nest/__init__.py index 2c7abe6f6c49..092e8ea08d63 100644 --- a/homeassistant/components/nest/__init__.py +++ b/homeassistant/components/nest/__init__.py @@ -22,10 +22,6 @@ from google_nest_sdm.exceptions import ( import voluptuous as vol from homeassistant.auth.permissions.const import POLICY_READ -from homeassistant.components.application_credentials import ( - ClientCredential, - async_import_client_credential, -) from homeassistant.components.camera import Image, img_util from homeassistant.components.http import KEY_HASS_USER from homeassistant.components.http.view import HomeAssistantView @@ -52,11 +48,6 @@ from homeassistant.helpers import ( entity_registry as er, ) from homeassistant.helpers.entity_registry import async_entries_for_device -from homeassistant.helpers.issue_registry import ( - IssueSeverity, - async_create_issue, - async_delete_issue, -) from homeassistant.helpers.typing import ConfigType from . import api, config_flow @@ -69,8 +60,6 @@ from .const import ( DATA_SDM, DATA_SUBSCRIBER, DOMAIN, - INSTALLED_AUTH_DOMAIN, - WEB_AUTH_DOMAIN, ) from .events import EVENT_NAME_MAP, NEST_EVENT from .legacy import async_setup_legacy, async_setup_legacy_entry @@ -128,9 +117,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if DOMAIN not in config: return True # ConfigMode.SDM_APPLICATION_CREDENTIALS - # Note that configuration.yaml deprecation warnings are handled in the - # config entry since we don't know what type of credentials we have and - # whether or not they can be imported. hass.data[DOMAIN][DATA_NEST_CONFIG] = config[DOMAIN] config_mode = config_flow.get_config_mode(hass) @@ -185,15 +171,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if DATA_SDM not in entry.data or config_mode == config_flow.ConfigMode.LEGACY: return await async_setup_legacy_entry(hass, entry) - if config_mode == config_flow.ConfigMode.SDM: - await async_import_config(hass, entry) - elif entry.unique_id != entry.data[CONF_PROJECT_ID]: + if entry.unique_id != entry.data[CONF_PROJECT_ID]: hass.config_entries.async_update_entry( entry, unique_id=entry.data[CONF_PROJECT_ID] ) - async_delete_issue(hass, DOMAIN, "removed_app_auth") - subscriber = await api.new_subscriber(hass, entry) if not subscriber: return False @@ -239,71 +221,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_import_config(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Attempt to import configuration.yaml settings.""" - config = hass.data[DOMAIN][DATA_NEST_CONFIG] - new_data = { - CONF_PROJECT_ID: config[CONF_PROJECT_ID], - **entry.data, - } - if CONF_SUBSCRIBER_ID not in entry.data: - if CONF_SUBSCRIBER_ID not in config: - raise ValueError("Configuration option 'subscriber_id' missing") - new_data.update( - { - CONF_SUBSCRIBER_ID: config[CONF_SUBSCRIBER_ID], - # Don't delete user managed subscriber - CONF_SUBSCRIBER_ID_IMPORTED: True, - } - ) - hass.config_entries.async_update_entry( - entry, data=new_data, unique_id=new_data[CONF_PROJECT_ID] - ) - - if entry.data["auth_implementation"] == INSTALLED_AUTH_DOMAIN: - # App Auth credentials have been deprecated and must be re-created - # by the user in the config flow - async_create_issue( - hass, - DOMAIN, - "removed_app_auth", - is_fixable=False, - severity=IssueSeverity.ERROR, - translation_key="removed_app_auth", - translation_placeholders={ - "more_info_url": ( - "https://www.home-assistant.io/more-info/nest-auth-deprecation" - ), - "documentation_url": "https://www.home-assistant.io/integrations/nest/", - }, - ) - raise ConfigEntryAuthFailed( - "Google has deprecated App Auth credentials, and the integration " - "must be reconfigured in the UI to restore access to Nest Devices." - ) - - if entry.data["auth_implementation"] == WEB_AUTH_DOMAIN: - await async_import_client_credential( - hass, - DOMAIN, - ClientCredential( - config[CONF_CLIENT_ID], - config[CONF_CLIENT_SECRET], - ), - WEB_AUTH_DOMAIN, - ) - - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml", - breaks_in_ha_version="2022.10.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - ) - - async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" if DATA_SDM not in entry.data: diff --git a/homeassistant/components/nest/config_flow.py b/homeassistant/components/nest/config_flow.py index a4642c38e846..d20057f4e28e 100644 --- a/homeassistant/components/nest/config_flow.py +++ b/homeassistant/components/nest/config_flow.py @@ -43,7 +43,6 @@ from .const import ( DATA_NEST_CONFIG, DATA_SDM, DOMAIN, - INSTALLED_AUTH_DOMAIN, OAUTH2_AUTHORIZE, SDM_SCOPES, ) @@ -64,10 +63,6 @@ PUBSUB_API_URL = "https://console.cloud.google.com/apis/library/pubsub.googleapi # URLs for Configure Device Access Project step DEVICE_ACCESS_CONSOLE_URL = "https://console.nest.google.com/device-access/" -# URLs for App Auth deprecation and upgrade -UPGRADE_MORE_INFO_URL = ( - "https://www.home-assistant.io/integrations/nest/#deprecated-app-auth-credentials" -) DEVICE_ACCESS_CONSOLE_EDIT_URL = ( "https://console.nest.google.com/device-access/project/{project_id}/information" ) @@ -161,7 +156,6 @@ class NestFlowHandler( def __init__(self) -> None: """Initialize NestFlowHandler.""" super().__init__() - self._upgrade = False self._data: dict[str, Any] = {DATA_SDM: {}} # Possible name to use for config entry based on the Google Home name self._structure_config_title: str | None = None @@ -233,38 +227,8 @@ class NestFlowHandler( assert self.config_mode != ConfigMode.LEGACY, "Step only supported for SDM API" if user_input is None: return self.async_show_form(step_id="reauth_confirm") - if self._data["auth_implementation"] == INSTALLED_AUTH_DOMAIN: - # The config entry points to an auth mechanism that no longer works and the - # user needs to take action in the google cloud console to resolve. First - # prompt to create app creds, then later ensure they've updated the device - # access console. - self._upgrade = True - implementations = await config_entry_oauth2_flow.async_get_implementations( - self.hass, self.DOMAIN - ) - if not implementations: - return await self.async_step_auth_upgrade() return await self.async_step_user() - async def async_step_auth_upgrade( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Give instructions for upgrade of deprecated app auth.""" - assert self.config_mode != ConfigMode.LEGACY, "Step only supported for SDM API" - if user_input is None: - return self.async_show_form( - step_id="auth_upgrade", - description_placeholders={ - "more_info_url": UPGRADE_MORE_INFO_URL, - }, - ) - # Abort this flow and ask the user for application credentials. The frontend - # will restart a new config flow after the user finishes so schedule a new - # re-auth config flow for the same entry so the user may resume. - if reauth_entry := self._async_reauth_entry(): - self.hass.async_add_job(reauth_entry.async_start_reauth, self.hass) - return self.async_abort(reason="missing_credentials") - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -358,39 +322,6 @@ class NestFlowHandler( errors=errors, ) - async def async_step_auth( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Verify any last pre-requisites before sending user through OAuth flow.""" - if user_input is None and self._upgrade: - # During app auth upgrade we need the user to update their device - # access project before we redirect to the authentication flow. - return await self.async_step_device_project_upgrade() - return await super().async_step_auth(user_input) - - async def async_step_device_project_upgrade( - self, user_input: dict | None = None - ) -> FlowResult: - """Update the device access project.""" - if user_input is not None: - # Resume OAuth2 redirects - return await super().async_step_auth() - if not isinstance( - self.flow_impl, config_entry_oauth2_flow.LocalOAuth2Implementation - ): - raise TypeError(f"Unexpected OAuth implementation: {self.flow_impl}") - client_id = self.flow_impl.client_id - return self.async_show_form( - step_id="device_project_upgrade", - description_placeholders={ - "device_access_console_url": DEVICE_ACCESS_CONSOLE_EDIT_URL.format( - project_id=self._data[CONF_PROJECT_ID] - ), - "more_info_url": UPGRADE_MORE_INFO_URL, - "client_id": client_id, - }, - ) - async def async_step_pubsub( self, user_input: dict[str, Any] | None = None ) -> FlowResult: diff --git a/homeassistant/components/nest/strings.json b/homeassistant/components/nest/strings.json index 05ccae00d057..2578437acf47 100644 --- a/homeassistant/components/nest/strings.json +++ b/homeassistant/components/nest/strings.json @@ -4,14 +4,6 @@ }, "config": { "step": { - "auth_upgrade": { - "title": "Nest: App Auth Deprecation", - "description": "App Auth has been deprecated by Google to improve security, and you need to take action by creating new application credentials.\n\nOpen the [documentation]({more_info_url}) to follow along as the next steps will guide you through the steps you need to take to restore access to your Nest devices." - }, - "device_project_upgrade": { - "title": "Nest: Update Device Access Project", - "description": "Update the Nest Device Access Project with your new OAuth Client ID ([more info]({more_info_url}))\n1. Go to the [Device Access Console]({device_access_console_url}).\n1. Click the trash icon next to *OAuth Client ID*.\n1. Click the `...` overflow menu and *Add Client ID*.\n1. Enter your new OAuth Client ID and click **Add**.\n\nYour OAuth Client ID is: `{client_id}`" - }, "create_cloud_project": { "title": "Nest: Create and configure Cloud Project", "description": "The Nest integration allows you to integrate your Nest Thermostats, Cameras, and Doorbells using the Smart Device Management API. The SDM API **requires a US $5** one time setup fee. See documentation for [more info]({more_info_url}).\n\n1. Go to the [Google Cloud Console]({cloud_console_url}).\n1. If this is your first project, click **Create Project** then **New Project**.\n1. Give your Cloud Project a Name and then click **Create**.\n1. Save the Cloud Project ID e.g. *example-project-12345* as you will need it later\n1. Go to API Library for [Smart Device Management API]({sdm_api_url}) and click **Enable**.\n1. Go to API Library for [Cloud Pub/Sub API]({pubsub_api_url}) and click **Enable**.\n\nProceed when your cloud project is set up." @@ -90,14 +82,6 @@ } }, "issues": { - "deprecated_yaml": { - "title": "The Nest YAML configuration is being removed", - "description": "Configuring Nest in configuration.yaml is being removed in Home Assistant 2022.10.\n\nYour existing OAuth Application Credentials and access settings have been imported into the UI automatically. Remove the YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - }, - "removed_app_auth": { - "title": "Nest Authentication Credentials must be updated", - "description": "To improve security and reduce phishing risk Google has deprecated the authentication method used by Home Assistant.\n\n**This requires action by you to resolve** ([more info]({more_info_url}))\n\n1. Visit the integrations page\n1. Click Reconfigure on the Nest integration.\n1. Home Assistant will walk you through the steps to upgrade to Web Authentication.\n\nSee the Nest [integration instructions]({documentation_url}) for troubleshooting information." - }, "legacy_nest_deprecated": { "title": "Legacy Works With Nest is being removed", "description": "Legacy Works With Nest is being removed from Home Assistant.\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." diff --git a/tests/components/nest/common.py b/tests/components/nest/common.py index fbdc2665879e..e2cd536725fc 100644 --- a/tests/components/nest/common.py +++ b/tests/components/nest/common.py @@ -17,9 +17,6 @@ from google_nest_sdm.google_nest_subscriber import GoogleNestSubscriber from homeassistant.components.application_credentials import ClientCredential from homeassistant.components.nest import DOMAIN -from homeassistant.components.nest.const import SDM_SCOPES - -from tests.common import MockConfigEntry # Typing helpers PlatformSetup = Callable[[], Awaitable[None]] @@ -36,98 +33,28 @@ CLOUD_PROJECT_ID = "cloud-id-9876" SUBSCRIBER_ID = "projects/cloud-id-9876/subscriptions/subscriber-id-9876" -CONFIG = { - "nest": { - "client_id": CLIENT_ID, - "client_secret": CLIENT_SECRET, - "project_id": PROJECT_ID, - "subscriber_id": SUBSCRIBER_ID, - }, -} - -FAKE_TOKEN = "some-token" -FAKE_REFRESH_TOKEN = "some-refresh-token" - - -def create_token_entry(token_expiration_time=None): - """Create OAuth 'token' data for a ConfigEntry.""" - if token_expiration_time is None: - token_expiration_time = time.time() + 86400 - return { - "access_token": FAKE_TOKEN, - "refresh_token": FAKE_REFRESH_TOKEN, - "scope": " ".join(SDM_SCOPES), - "token_type": "Bearer", - "expires_at": token_expiration_time, - } - - -def create_config_entry(token_expiration_time=None) -> MockConfigEntry: - """Create a ConfigEntry and add it to Home Assistant.""" - config_entry_data = { - "sdm": {}, # Indicates new SDM API, not legacy API - "auth_implementation": "nest", - "token": create_token_entry(token_expiration_time), - } - return MockConfigEntry(domain=DOMAIN, data=config_entry_data) - - @dataclass class NestTestConfig: """Holder for integration configuration.""" config: dict[str, Any] = field(default_factory=dict) config_entry_data: dict[str, Any] | None = None - auth_implementation: str = WEB_AUTH_DOMAIN credential: ClientCredential | None = None -# Exercises mode where all configuration is in configuration.yaml -TEST_CONFIG_YAML_ONLY = NestTestConfig( - config=CONFIG, - config_entry_data={ - "sdm": {}, - "token": create_token_entry(), - }, -) -TEST_CONFIGFLOW_YAML_ONLY = NestTestConfig( - config=TEST_CONFIG_YAML_ONLY.config, -) - -# Exercises mode where subscriber id is created in the config flow, but -# all authentication is defined in configuration.yaml -TEST_CONFIG_HYBRID = NestTestConfig( - config={ - "nest": { - "client_id": CLIENT_ID, - "client_secret": CLIENT_SECRET, - "project_id": PROJECT_ID, - }, - }, - config_entry_data={ - "sdm": {}, - "token": create_token_entry(), - "cloud_project_id": CLOUD_PROJECT_ID, - "subscriber_id": SUBSCRIBER_ID, - }, -) -TEST_CONFIGFLOW_HYBRID = NestTestConfig(TEST_CONFIG_HYBRID.config) - # Exercises mode where all configuration is from the config flow TEST_CONFIG_APP_CREDS = NestTestConfig( config_entry_data={ "sdm": {}, - "token": create_token_entry(), "project_id": PROJECT_ID, "cloud_project_id": CLOUD_PROJECT_ID, "subscriber_id": SUBSCRIBER_ID, + "auth_implementation": "imported-cred", }, - auth_implementation="imported-cred", credential=ClientCredential(CLIENT_ID, CLIENT_SECRET), ) TEST_CONFIGFLOW_APP_CREDS = NestTestConfig( config=TEST_CONFIG_APP_CREDS.config, - auth_implementation="imported-cred", credential=ClientCredential(CLIENT_ID, CLIENT_SECRET), ) diff --git a/tests/components/nest/conftest.py b/tests/components/nest/conftest.py index db89063553f3..6057db382b5a 100644 --- a/tests/components/nest/conftest.py +++ b/tests/components/nest/conftest.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Generator import copy import shutil +import time from typing import Any from unittest.mock import AsyncMock, patch import uuid @@ -18,7 +19,7 @@ from homeassistant.components.application_credentials import ( async_import_client_credential, ) from homeassistant.components.nest import DOMAIN -from homeassistant.components.nest.const import CONF_SUBSCRIBER_ID +from homeassistant.components.nest.const import CONF_SUBSCRIBER_ID, SDM_SCOPES from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -27,7 +28,6 @@ from .common import ( PROJECT_ID, SUBSCRIBER_ID, TEST_CONFIG_APP_CREDS, - TEST_CONFIG_YAML_ONLY, CreateDevice, FakeSubscriber, NestTestConfig, @@ -37,6 +37,9 @@ from .common import ( from tests.common import MockConfigEntry +FAKE_TOKEN = "some-token" +FAKE_REFRESH_TOKEN = "some-refresh-token" + class FakeAuth(AbstractAuth): """A fake implementation of the auth class that records requests. @@ -186,18 +189,9 @@ def subscriber_id() -> str: @pytest.fixture -def auth_implementation(nest_test_config: NestTestConfig) -> str | None: - """Fixture to let tests override the auth implementation in the config entry.""" - return nest_test_config.auth_implementation - - -@pytest.fixture( - params=[TEST_CONFIG_YAML_ONLY, TEST_CONFIG_APP_CREDS], - ids=["yaml-config-only", "app-creds"], -) def nest_test_config(request) -> NestTestConfig: """Fixture that sets up the configuration used for the test.""" - return request.param + return TEST_CONFIG_APP_CREDS @pytest.fixture @@ -220,12 +214,30 @@ def config_entry_unique_id() -> str: return PROJECT_ID +@pytest.fixture +def token_expiration_time() -> float: + """Fixture for expiration time of the config entry auth token.""" + return time.time() + 86400 + + +@pytest.fixture +def token_entry(token_expiration_time: float) -> dict[str, Any]: + """Fixture for OAuth 'token' data for a ConfigEntry.""" + return { + "access_token": FAKE_TOKEN, + "refresh_token": FAKE_REFRESH_TOKEN, + "scope": " ".join(SDM_SCOPES), + "token_type": "Bearer", + "expires_at": token_expiration_time, + } + + @pytest.fixture def config_entry( subscriber_id: str | None, - auth_implementation: str | None, nest_test_config: NestTestConfig, config_entry_unique_id: str, + token_entry: dict[str, Any], ) -> MockConfigEntry | None: """Fixture that sets up the ConfigEntry for the test.""" if nest_test_config.config_entry_data is None: @@ -236,7 +248,7 @@ def config_entry( data[CONF_SUBSCRIBER_ID] = subscriber_id else: del data[CONF_SUBSCRIBER_ID] - data["auth_implementation"] = auth_implementation + data["token"] = token_entry return MockConfigEntry(domain=DOMAIN, data=data, unique_id=config_entry_unique_id) @@ -247,10 +259,7 @@ async def credential(hass: HomeAssistant, nest_test_config: NestTestConfig) -> N return assert await async_setup_component(hass, "application_credentials", {}) await async_import_client_credential( - hass, - DOMAIN, - nest_test_config.credential, - nest_test_config.auth_implementation, + hass, DOMAIN, nest_test_config.credential, "imported-cred" ) diff --git a/tests/components/nest/test_api.py b/tests/components/nest/test_api.py index 0be71be1de58..422f1fe62696 100644 --- a/tests/components/nest/test_api.py +++ b/tests/components/nest/test_api.py @@ -12,43 +12,38 @@ from unittest.mock import patch import pytest -from homeassistant.components.nest import DOMAIN from homeassistant.components.nest.const import API_URL, OAUTH2_TOKEN, SDM_SCOPES from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from homeassistant.util import dt -from .common import ( - CLIENT_ID, - CLIENT_SECRET, - CONFIG, - FAKE_REFRESH_TOKEN, - FAKE_TOKEN, - PROJECT_ID, - TEST_CONFIGFLOW_YAML_ONLY, - create_config_entry, -) +from .common import CLIENT_ID, CLIENT_SECRET, PROJECT_ID, PlatformSetup +from .conftest import FAKE_REFRESH_TOKEN, FAKE_TOKEN from tests.test_util.aiohttp import AiohttpClientMocker FAKE_UPDATED_TOKEN = "fake-updated-token" -async def async_setup_sdm(hass): - """Set up the integration.""" - assert await async_setup_component(hass, DOMAIN, CONFIG) - await hass.async_block_till_done() +@pytest.fixture +def subscriber() -> None: + """Disable default subscriber since tests use their own patch.""" + return None # This tests needs to be adjusted to remove lingering tasks @pytest.mark.parametrize("expected_lingering_tasks", [True]) -@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_YAML_ONLY]) -async def test_auth(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None: +@pytest.mark.parametrize( + "token_expiration_time", + [time.time() + 7 * 86400], + ids=["expires-in-future"], +) +async def test_auth( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + setup_platform: PlatformSetup, + token_expiration_time: float, +) -> None: """Exercise authentication library creates valid credentials.""" - - expiration_time = time.time() + 86400 - create_config_entry(expiration_time).add_to_hass(hass) - # Prepare to capture credentials in API request. Empty payloads just mean # no devices or structures are loaded. aioclient_mock.get(f"{API_URL}/enterprises/{PROJECT_ID}/structures", json={}) @@ -69,7 +64,7 @@ async def test_auth(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> "google_nest_sdm.google_nest_subscriber.DefaultSubscriberFactory.async_new_subscriber", side_effect=async_new_subscriber, ) as new_subscriber_mock: - await async_setup_sdm(hass) + await setup_platform() # Verify API requests are made with the correct credentials calls = aioclient_mock.mock_calls @@ -85,7 +80,7 @@ async def test_auth(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> creds = captured_creds assert creds.token == FAKE_TOKEN assert creds.refresh_token == FAKE_REFRESH_TOKEN - assert int(dt.as_timestamp(creds.expiry)) == int(expiration_time) + assert int(dt.as_timestamp(creds.expiry)) == int(token_expiration_time) assert creds.valid assert not creds.expired assert creds.token_uri == OAUTH2_TOKEN @@ -96,15 +91,18 @@ async def test_auth(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> # This tests needs to be adjusted to remove lingering tasks @pytest.mark.parametrize("expected_lingering_tasks", [True]) -@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_YAML_ONLY]) +@pytest.mark.parametrize( + "token_expiration_time", + [time.time() - 7 * 86400], + ids=["expires-in-past"], +) async def test_auth_expired_token( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + setup_platform: PlatformSetup, + token_expiration_time: float, ) -> None: """Verify behavior of an expired token.""" - - expiration_time = time.time() - 86400 - create_config_entry(expiration_time).add_to_hass(hass) - # Prepare a token refresh response aioclient_mock.post( OAUTH2_TOKEN, @@ -134,7 +132,7 @@ async def test_auth_expired_token( "google_nest_sdm.google_nest_subscriber.DefaultSubscriberFactory.async_new_subscriber", side_effect=async_new_subscriber, ) as new_subscriber_mock: - await async_setup_sdm(hass) + await setup_platform() calls = aioclient_mock.mock_calls assert len(calls) == 3 @@ -159,7 +157,7 @@ async def test_auth_expired_token( creds = captured_creds assert creds.token == FAKE_TOKEN assert creds.refresh_token == FAKE_REFRESH_TOKEN - assert int(dt.as_timestamp(creds.expiry)) == int(expiration_time) + assert int(dt.as_timestamp(creds.expiry)) == int(token_expiration_time) assert not creds.valid assert creds.expired assert creds.token_uri == OAUTH2_TOKEN diff --git a/tests/components/nest/test_config_flow_sdm.py b/tests/components/nest/test_config_flow_sdm.py index cdeaed40f47a..7ab4a6dafc13 100644 --- a/tests/components/nest/test_config_flow_sdm.py +++ b/tests/components/nest/test_config_flow_sdm.py @@ -14,10 +14,6 @@ import pytest from homeassistant import config_entries from homeassistant.components import dhcp -from homeassistant.components.application_credentials import ( - ClientCredential, - async_import_client_credential, -) from homeassistant.components.nest.const import DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -25,23 +21,17 @@ from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_entry_oauth2_flow from .common import ( - APP_AUTH_DOMAIN, CLIENT_ID, - CLIENT_SECRET, CLOUD_PROJECT_ID, - FAKE_TOKEN, PROJECT_ID, SUBSCRIBER_ID, TEST_CONFIG_APP_CREDS, - TEST_CONFIG_HYBRID, - TEST_CONFIG_YAML_ONLY, TEST_CONFIGFLOW_APP_CREDS, - TEST_CONFIGFLOW_YAML_ONLY, - WEB_AUTH_DOMAIN, - MockConfigEntry, NestTestConfig, ) +from tests.common import MockConfigEntry + WEB_REDIRECT_URL = "https://example.com/auth/external/callback" APP_REDIRECT_URL = "urn:ietf:wg:oauth:2.0:oob" @@ -51,6 +41,12 @@ FAKE_DHCP_DATA = dhcp.DhcpServiceInfo( ) +@pytest.fixture +def nest_test_config(request) -> NestTestConfig: + """Fixture with empty configuration and no existing config entry.""" + return TEST_CONFIGFLOW_APP_CREDS + + class OAuthFixture: """Simulate the oauth flow used by the config flow.""" @@ -196,7 +192,6 @@ async def oauth(hass, hass_client_no_auth, aioclient_mock, current_request_with_ return OAuthFixture(hass, hass_client_no_auth, aioclient_mock) -@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_APP_CREDS]) async def test_app_credentials( hass: HomeAssistant, oauth, subscriber, setup_platform ) -> None: @@ -230,7 +225,6 @@ async def test_app_credentials( } -@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_APP_CREDS]) async def test_config_flow_restart( hass: HomeAssistant, oauth, subscriber, setup_platform ) -> None: @@ -283,7 +277,6 @@ async def test_config_flow_restart( } -@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_APP_CREDS]) async def test_config_flow_wrong_project_id( hass: HomeAssistant, oauth, subscriber, setup_platform ) -> None: @@ -335,7 +328,6 @@ async def test_config_flow_wrong_project_id( } -@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_APP_CREDS]) async def test_config_flow_pubsub_configuration_error( hass: HomeAssistant, oauth, @@ -358,7 +350,6 @@ async def test_config_flow_pubsub_configuration_error( assert result["errors"]["cloud_project_id"] == "bad_project_id" -@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_APP_CREDS]) async def test_config_flow_pubsub_subscriber_error( hass: HomeAssistant, oauth, setup_platform, mock_subscriber ) -> None: @@ -379,50 +370,7 @@ async def test_config_flow_pubsub_subscriber_error( assert result["errors"]["cloud_project_id"] == "subscriber_error" -@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_YAML_ONLY]) -async def test_config_yaml_ignored(hass: HomeAssistant, oauth, setup_platform) -> None: - """Check full flow.""" - await setup_platform() - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - await hass.async_block_till_done() - assert result["type"] == "form" - assert result["step_id"] == "create_cloud_project" - - result = await oauth.async_configure(result, {}) - assert result.get("type") == "abort" - assert result.get("reason") == "missing_credentials" - - -@pytest.mark.parametrize("nest_test_config", [TEST_CONFIG_YAML_ONLY]) -async def test_web_reauth( - hass: HomeAssistant, oauth, setup_platform, config_entry -) -> None: - """Test Nest reauthentication.""" - await setup_platform() - - assert config_entry.data["token"].get("access_token") == FAKE_TOKEN - - orig_subscriber_id = config_entry.data.get("subscriber_id") - result = await oauth.async_reauth(config_entry) - - await oauth.async_oauth_web_flow(result) - entry = await oauth.async_finish_setup(result) - # Verify existing tokens are replaced - entry.data["token"].pop("expires_at") - assert entry.unique_id == PROJECT_ID - assert entry.data["token"] == { - "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", - "type": "Bearer", - "expires_in": 60, - } - assert entry.data["auth_implementation"] == WEB_AUTH_DOMAIN - assert entry.data.get("subscriber_id") == orig_subscriber_id # Not updated - - +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIG_APP_CREDS]) async def test_multiple_config_entries( hass: HomeAssistant, oauth, setup_platform ) -> None: @@ -444,6 +392,7 @@ async def test_multiple_config_entries( assert len(entries) == 2 +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIG_APP_CREDS]) async def test_duplicate_config_entries( hass: HomeAssistant, oauth, setup_platform ) -> None: @@ -468,6 +417,7 @@ async def test_duplicate_config_entries( assert result.get("reason") == "already_configured" +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIG_APP_CREDS]) async def test_reauth_multiple_config_entries( hass: HomeAssistant, oauth, setup_platform, config_entry ) -> None: @@ -517,102 +467,6 @@ async def test_reauth_multiple_config_entries( assert entry.data.get("extra_data") -@pytest.mark.parametrize( - ("nest_test_config", "auth_implementation"), [(TEST_CONFIG_HYBRID, APP_AUTH_DOMAIN)] -) -async def test_app_auth_yaml_reauth( - hass: HomeAssistant, oauth, setup_platform, config_entry -) -> None: - """Test reauth for deprecated app auth credentails upgrade instructions.""" - - await setup_platform() - - orig_subscriber_id = config_entry.data.get("subscriber_id") - assert config_entry.data["auth_implementation"] == APP_AUTH_DOMAIN - - result = oauth.async_progress() - assert result.get("step_id") == "reauth_confirm" - - result = await oauth.async_configure(result, {}) - assert result.get("type") == "form" - assert result.get("step_id") == "auth_upgrade" - - result = await oauth.async_configure(result, {}) - assert result.get("type") == "abort" - assert result.get("reason") == "missing_credentials" - await hass.async_block_till_done() - # Config flow is aborted, but new one created back in re-auth state waiting for user - # to create application credentials - flows = hass.config_entries.flow.async_progress() - assert len(flows) == 1 - - # Emulate user entering credentials (different from configuration.yaml creds) - await async_import_client_credential( - hass, - DOMAIN, - ClientCredential(CLIENT_ID, CLIENT_SECRET), - ) - - # Config flow is placed back into a reuath state - result = oauth.async_progress() - assert result.get("step_id") == "reauth_confirm" - - result = await oauth.async_configure(result, {}) - assert result.get("type") == "form" - assert result.get("step_id") == "device_project_upgrade" - - # Frontend sends user back through the config flow again - result = await oauth.async_configure(result, {}) - await oauth.async_oauth_web_flow(result) - - # Verify existing tokens are replaced - entry = await oauth.async_finish_setup(result, {"code": "1234"}) - entry.data["token"].pop("expires_at") - assert entry.unique_id == PROJECT_ID - assert entry.data["token"] == { - "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", - "type": "Bearer", - "expires_in": 60, - } - assert entry.data["auth_implementation"] == DOMAIN - assert entry.data.get("subscriber_id") == orig_subscriber_id # Not updated - - # Existing entry is updated - assert config_entry.data["auth_implementation"] == DOMAIN - - -@pytest.mark.parametrize( - ("nest_test_config", "auth_implementation"), - [(TEST_CONFIG_YAML_ONLY, WEB_AUTH_DOMAIN)], -) -async def test_web_auth_yaml_reauth( - hass: HomeAssistant, oauth, setup_platform, config_entry -) -> None: - """Test Nest reauthentication for Installed App Auth.""" - - await setup_platform() - - orig_subscriber_id = config_entry.data.get("subscriber_id") - - result = await oauth.async_reauth(config_entry) - await oauth.async_oauth_web_flow(result) - - # Verify existing tokens are replaced - entry = await oauth.async_finish_setup(result, {"code": "1234"}) - entry.data["token"].pop("expires_at") - assert entry.unique_id == PROJECT_ID - assert entry.data["token"] == { - "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", - "type": "Bearer", - "expires_in": 60, - } - assert entry.data["auth_implementation"] == WEB_AUTH_DOMAIN - assert entry.data.get("subscriber_id") == orig_subscriber_id # Not updated - - -@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_APP_CREDS]) async def test_pubsub_subscription_strip_whitespace( hass: HomeAssistant, oauth, subscriber, setup_platform ) -> None: @@ -641,7 +495,6 @@ async def test_pubsub_subscription_strip_whitespace( assert entry.data["cloud_project_id"] == CLOUD_PROJECT_ID -@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_APP_CREDS]) async def test_pubsub_subscription_auth_failure( hass: HomeAssistant, oauth, setup_platform, mock_subscriber ) -> None: @@ -668,7 +521,6 @@ async def test_pubsub_subscriber_config_entry_reauth( setup_platform, subscriber, config_entry, - auth_implementation, ) -> None: """Test the pubsub subscriber id is preserved during reauth.""" await setup_platform() @@ -686,12 +538,11 @@ async def test_pubsub_subscriber_config_entry_reauth( "type": "Bearer", "expires_in": 60, } - assert entry.data["auth_implementation"] == auth_implementation + assert entry.data["auth_implementation"] == "imported-cred" assert entry.data["subscriber_id"] == SUBSCRIBER_ID assert entry.data["cloud_project_id"] == CLOUD_PROJECT_ID -@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_APP_CREDS]) async def test_config_entry_title_from_home( hass: HomeAssistant, oauth, setup_platform, subscriber ) -> None: @@ -725,7 +576,6 @@ async def test_config_entry_title_from_home( assert entry.data["cloud_project_id"] == CLOUD_PROJECT_ID -@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_APP_CREDS]) async def test_config_entry_title_multiple_homes( hass: HomeAssistant, oauth, setup_platform, subscriber ) -> None: @@ -768,7 +618,6 @@ async def test_config_entry_title_multiple_homes( assert entry.title == "Example Home #1, Example Home #2" -@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_APP_CREDS]) async def test_title_failure_fallback( hass: HomeAssistant, oauth, setup_platform, mock_subscriber ) -> None: @@ -788,7 +637,6 @@ async def test_title_failure_fallback( assert entry.data["cloud_project_id"] == CLOUD_PROJECT_ID -@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_APP_CREDS]) async def test_structure_missing_trait( hass: HomeAssistant, oauth, setup_platform, subscriber ) -> None: @@ -818,7 +666,9 @@ async def test_structure_missing_trait( @pytest.mark.parametrize("nest_test_config", [NestTestConfig()]) -async def test_dhcp_discovery(hass: HomeAssistant, oauth, subscriber) -> None: +async def test_dhcp_discovery( + hass: HomeAssistant, oauth: OAuthFixture, nest_test_config: NestTestConfig +) -> None: """Exercise discovery dhcp starts the config flow and kicks user to frontend creds flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, @@ -834,7 +684,6 @@ async def test_dhcp_discovery(hass: HomeAssistant, oauth, subscriber) -> None: assert result.get("reason") == "missing_credentials" -@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_APP_CREDS]) async def test_dhcp_discovery_with_creds( hass: HomeAssistant, oauth, subscriber, setup_platform ) -> None: diff --git a/tests/components/nest/test_init_sdm.py b/tests/components/nest/test_init_sdm.py index 07df63a2e26d..db560e44e832 100644 --- a/tests/components/nest/test_init_sdm.py +++ b/tests/components/nest/test_init_sdm.py @@ -26,9 +26,6 @@ from homeassistant.core import HomeAssistant from .common import ( PROJECT_ID, SUBSCRIBER_ID, - TEST_CONFIG_APP_CREDS, - TEST_CONFIG_HYBRID, - TEST_CONFIG_YAML_ONLY, TEST_CONFIGFLOW_APP_CREDS, FakeSubscriber, YieldFixture, @@ -180,10 +177,7 @@ async def test_subscriber_configuration_failure( assert entries[0].state is ConfigEntryState.SETUP_ERROR -@pytest.mark.parametrize( - "nest_test_config", - [TEST_CONFIGFLOW_APP_CREDS], -) +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_APP_CREDS]) async def test_empty_config( hass: HomeAssistant, error_caplog, config, setup_platform ) -> None: @@ -208,26 +202,9 @@ async def test_unload_entry(hass: HomeAssistant, setup_platform) -> None: assert entry.state == ConfigEntryState.NOT_LOADED -@pytest.mark.parametrize( - ("nest_test_config", "delete_called"), - [ - ( - TEST_CONFIG_YAML_ONLY, - False, - ), # User manually created subscriber, preserve on remove - ( - TEST_CONFIG_HYBRID, - True, - ), # Integration created subscriber, garbage collect on remove - ( - TEST_CONFIG_APP_CREDS, - True, - ), # Integration created subscriber, garbage collect on remove - ], - ids=["yaml-config-only", "hybrid-config", "config-entry"], -) async def test_remove_entry( - hass: HomeAssistant, nest_test_config, setup_base_platform, delete_called + hass: HomeAssistant, + setup_base_platform, ) -> None: """Test successful unload of a ConfigEntry.""" with patch( @@ -250,19 +227,14 @@ async def test_remove_entry( "homeassistant.components.nest.api.GoogleNestSubscriber.delete_subscription", ) as delete: assert await hass.config_entries.async_remove(entry.entry_id) - assert delete.called == delete_called + assert delete.called entries = hass.config_entries.async_entries(DOMAIN) assert not entries -@pytest.mark.parametrize( - "nest_test_config", - [TEST_CONFIG_HYBRID, TEST_CONFIG_APP_CREDS], - ids=["hyrbid-config", "app-creds"], -) async def test_remove_entry_delete_subscriber_failure( - hass: HomeAssistant, nest_test_config, setup_base_platform + hass: HomeAssistant, setup_base_platform ) -> None: """Test a failure when deleting the subscription.""" with patch( From 32f6dd4a8293ff8e6681dc936f58e245ea5287b2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 30 Apr 2023 22:11:29 -0500 Subject: [PATCH 115/822] Bump sqlalchemy to 2.0.12 (#92315) changelog: https://docs.sqlalchemy.org/en/20/changelog/changelog_20.html#change-2.0.12 --- homeassistant/components/recorder/manifest.json | 2 +- homeassistant/components/sql/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index d08a3b45b340..85190e25f4a1 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.11", + "sqlalchemy==2.0.12", "fnv-hash-fast==0.3.1", "psutil-home-assistant==0.0.1" ] diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index 0dfbc0c53935..0a70d9e2795a 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.11"] + "requirements": ["sqlalchemy==2.0.12"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 2593d34e1b45..9a1b6654af12 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -45,7 +45,7 @@ pyudev==0.23.2 pyyaml==6.0 requests==2.29.0 scapy==2.5.0 -sqlalchemy==2.0.11 +sqlalchemy==2.0.12 typing-extensions>=4.5.0,<5.0 ulid-transform==0.7.0 voluptuous-serialize==2.6.0 diff --git a/requirements_all.txt b/requirements_all.txt index 574d6573abef..3d2a33c7c96b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2397,7 +2397,7 @@ spotipy==2.23.0 # homeassistant.components.recorder # homeassistant.components.sql -sqlalchemy==2.0.11 +sqlalchemy==2.0.12 # homeassistant.components.srp_energy srpenergy==1.3.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 94a06d59d892..b90e6ff1f429 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1730,7 +1730,7 @@ spotipy==2.23.0 # homeassistant.components.recorder # homeassistant.components.sql -sqlalchemy==2.0.11 +sqlalchemy==2.0.12 # homeassistant.components.srp_energy srpenergy==1.3.6 From 30dd8b9f3a1d327af905bb1cd34434110734b445 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 30 Apr 2023 22:12:01 -0500 Subject: [PATCH 116/822] Retry onvif setup when it is unexpectedly cancelled (#92313) * Retry onvif setup when it is unexpectedly cancelled fixes #92308 * Retry onvif setup when it is unexpectedly cancelled fixes #92308 --- homeassistant/components/onvif/__init__.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/homeassistant/components/onvif/__init__.py b/homeassistant/components/onvif/__init__.py index ec894befaeaf..2c96b79cbeba 100644 --- a/homeassistant/components/onvif/__init__.py +++ b/homeassistant/components/onvif/__init__.py @@ -1,4 +1,5 @@ """The ONVIF integration.""" +import asyncio import logging from httpx import RequestError @@ -57,6 +58,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady( f"Could not setup camera {device.device.host}:{device.device.port}: {err}" ) from err + except asyncio.CancelledError as err: + # After https://github.com/agronholm/anyio/issues/374 is resolved + # this may be able to be removed + await device.device.close() + raise ConfigEntryNotReady(f"Setup was unexpectedly canceled: {err}") from err if not device.available: raise ConfigEntryNotReady() From ede1f08c5191f39d9a44aa367466ce11920f0f06 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 30 Apr 2023 22:18:00 -0500 Subject: [PATCH 117/822] Reduce size of migration transactions to accommodate slow/busy systems (#92312) * Reduce size of migration transactions to accommodate slow/busy systems related issue #91489 * handle overloaded RPIs better --- .../components/recorder/migration.py | 20 +++++++++---------- .../components/recorder/statistics.py | 4 ++-- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 1d2b56fb434b..b8436da97d58 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -1158,23 +1158,23 @@ def _wipe_old_string_time_columns( elif engine.dialect.name == SupportedDialect.MYSQL: # # Since this is only to save space we limit the number of rows we update - # to 10,000,000 per table since we do not want to block the database for too long + # to 100,000 per table since we do not want to block the database for too long # or run out of innodb_buffer_pool_size on MySQL. The old data will eventually # be cleaned up by the recorder purge if we do not do it now. # - session.execute(text("UPDATE events set time_fired=NULL LIMIT 10000000;")) + session.execute(text("UPDATE events set time_fired=NULL LIMIT 100000;")) session.commit() session.execute( text( "UPDATE states set last_updated=NULL, last_changed=NULL " - " LIMIT 10000000;" + " LIMIT 100000;" ) ) session.commit() elif engine.dialect.name == SupportedDialect.POSTGRESQL: # # Since this is only to save space we limit the number of rows we update - # to 250,000 per table since we do not want to block the database for too long + # to 100,000 per table since we do not want to block the database for too long # or run out ram with postgresql. The old data will eventually # be cleaned up by the recorder purge if we do not do it now. # @@ -1182,7 +1182,7 @@ def _wipe_old_string_time_columns( text( "UPDATE events set time_fired=NULL " "where event_id in " - "(select event_id from events where time_fired_ts is NOT NULL LIMIT 250000);" + "(select event_id from events where time_fired_ts is NOT NULL LIMIT 100000);" ) ) session.commit() @@ -1190,7 +1190,7 @@ def _wipe_old_string_time_columns( text( "UPDATE states set last_updated=NULL, last_changed=NULL " "where state_id in " - "(select state_id from states where last_updated_ts is NOT NULL LIMIT 250000);" + "(select state_id from states where last_updated_ts is NOT NULL LIMIT 100000);" ) ) session.commit() @@ -1236,7 +1236,7 @@ def _migrate_columns_to_timestamp( "UNIX_TIMESTAMP(time_fired)" ") " "where time_fired_ts is NULL " - "LIMIT 250000;" + "LIMIT 100000;" ) ) result = None @@ -1251,7 +1251,7 @@ def _migrate_columns_to_timestamp( "last_changed_ts=" "UNIX_TIMESTAMP(last_changed) " "where last_updated_ts is NULL " - "LIMIT 250000;" + "LIMIT 100000;" ) ) elif engine.dialect.name == SupportedDialect.POSTGRESQL: @@ -1266,7 +1266,7 @@ def _migrate_columns_to_timestamp( "time_fired_ts= " "(case when time_fired is NULL then 0 else EXTRACT(EPOCH FROM time_fired::timestamptz) end) " "WHERE event_id IN ( " - "SELECT event_id FROM events where time_fired_ts is NULL LIMIT 250000 " + "SELECT event_id FROM events where time_fired_ts is NULL LIMIT 100000 " " );" ) ) @@ -1279,7 +1279,7 @@ def _migrate_columns_to_timestamp( "(case when last_updated is NULL then 0 else EXTRACT(EPOCH FROM last_updated::timestamptz) end), " "last_changed_ts=EXTRACT(EPOCH FROM last_changed::timestamptz) " "where state_id IN ( " - "SELECT state_id FROM states where last_updated_ts is NULL LIMIT 250000 " + "SELECT state_id FROM states where last_updated_ts is NULL LIMIT 100000 " " );" ) ) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index b34d497b4b77..0e49c09e17ce 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -2333,7 +2333,7 @@ def cleanup_statistics_timestamp_migration(instance: Recorder) -> bool: session.connection() .execute( text( - f"UPDATE {table} set start=NULL, created=NULL, last_reset=NULL where start is not NULL LIMIT 250000;" + f"UPDATE {table} set start=NULL, created=NULL, last_reset=NULL where start is not NULL LIMIT 100000;" ) ) .rowcount @@ -2349,7 +2349,7 @@ def cleanup_statistics_timestamp_migration(instance: Recorder) -> bool: .execute( text( f"UPDATE {table} set start=NULL, created=NULL, last_reset=NULL " # nosec - f"where id in (select id from {table} where start is not NULL LIMIT 250000)" + f"where id in (select id from {table} where start is not NULL LIMIT 100000)" ) ) .rowcount From 7b5d26d3faf537ba616894ec9d7a070afbb67a31 Mon Sep 17 00:00:00 2001 From: Luke Date: Mon, 1 May 2023 05:15:29 -0400 Subject: [PATCH 118/822] Bump anova version (#92206) Co-authored-by: Franck Nijhof --- homeassistant/components/anova/__init__.py | 12 +--- homeassistant/components/anova/coordinator.py | 8 +-- homeassistant/components/anova/manifest.json | 2 +- homeassistant/components/anova/sensor.py | 64 +++++++++++++------ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/anova/__init__.py | 32 ++-------- 7 files changed, 61 insertions(+), 61 deletions(-) diff --git a/homeassistant/components/anova/__init__.py b/homeassistant/components/anova/__init__.py index 7810e00ded0b..2fee8a6beeb4 100644 --- a/homeassistant/components/anova/__init__.py +++ b/homeassistant/components/anova/__init__.py @@ -3,13 +3,7 @@ from __future__ import annotations import logging -from anova_wifi import ( - AnovaApi, - AnovaPrecisionCooker, - AnovaPrecisionCookerSensor, - InvalidLogin, - NoDevicesFound, -) +from anova_wifi import AnovaApi, AnovaPrecisionCooker, InvalidLogin, NoDevicesFound from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform @@ -67,9 +61,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinators = [AnovaCoordinator(hass, device) for device in devices] for coordinator in coordinators: await coordinator.async_config_entry_first_refresh() - firmware_version = coordinator.data["sensors"][ - AnovaPrecisionCookerSensor.FIRMWARE_VERSION - ] + firmware_version = coordinator.data.sensor.firmware_version coordinator.async_setup(str(firmware_version)) hass.data.setdefault(DOMAIN, {})[entry.entry_id] = AnovaData( api_jwt=api.jwt, precision_cookers=devices, coordinators=coordinators diff --git a/homeassistant/components/anova/coordinator.py b/homeassistant/components/anova/coordinator.py index cd4eab9c2e52..2e5505a9fdd2 100644 --- a/homeassistant/components/anova/coordinator.py +++ b/homeassistant/components/anova/coordinator.py @@ -2,7 +2,7 @@ from datetime import timedelta import logging -from anova_wifi import AnovaOffline, AnovaPrecisionCooker +from anova_wifi import AnovaOffline, AnovaPrecisionCooker, APCUpdate import async_timeout from homeassistant.core import HomeAssistant, callback @@ -14,11 +14,9 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -class AnovaCoordinator(DataUpdateCoordinator): +class AnovaCoordinator(DataUpdateCoordinator[APCUpdate]): """Anova custom coordinator.""" - data: dict[str, dict[str, str | int | float]] - def __init__( self, hass: HomeAssistant, @@ -47,7 +45,7 @@ class AnovaCoordinator(DataUpdateCoordinator): sw_version=firmware_version, ) - async def _async_update_data(self) -> dict[str, dict[str, str | int | float]]: + async def _async_update_data(self) -> APCUpdate: try: async with async_timeout.timeout(5): return await self.anova_device.update() diff --git a/homeassistant/components/anova/manifest.json b/homeassistant/components/anova/manifest.json index d307a9314f95..39608011a03c 100644 --- a/homeassistant/components/anova/manifest.json +++ b/homeassistant/components/anova/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/anova", "iot_class": "cloud_polling", "loggers": ["anova_wifi"], - "requirements": ["anova-wifi==0.8.0"] + "requirements": ["anova-wifi==0.9.0"] } diff --git a/homeassistant/components/anova/sensor.py b/homeassistant/components/anova/sensor.py index a5ea3ee2fd86..6336aa61e1c2 100644 --- a/homeassistant/components/anova/sensor.py +++ b/homeassistant/components/anova/sensor.py @@ -1,7 +1,10 @@ """Support for Anova Sensors.""" from __future__ import annotations -from anova_wifi import AnovaPrecisionCookerSensor +from collections.abc import Callable +from dataclasses import dataclass + +from anova_wifi import APCUpdateSensor from homeassistant import config_entries from homeassistant.components.sensor import ( @@ -19,57 +22,80 @@ from .const import DOMAIN from .entity import AnovaDescriptionEntity from .models import AnovaData + +@dataclass +class AnovaSensorEntityDescriptionMixin: + """Describes the mixin variables for anova sensors.""" + + value_fn: Callable[[APCUpdateSensor], float | int | str] + + +@dataclass +class AnovaSensorEntityDescription( + SensorEntityDescription, AnovaSensorEntityDescriptionMixin +): + """Describes a Anova sensor.""" + + SENSOR_DESCRIPTIONS: list[SensorEntityDescription] = [ - SensorEntityDescription( - key=AnovaPrecisionCookerSensor.COOK_TIME, + AnovaSensorEntityDescription( + key="cook_time", state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfTime.SECONDS, icon="mdi:clock-outline", translation_key="cook_time", + device_class=SensorDeviceClass.DURATION, + value_fn=lambda data: data.cook_time, ), - SensorEntityDescription( - key=AnovaPrecisionCookerSensor.STATE, translation_key="state" + AnovaSensorEntityDescription( + key="state", translation_key="state", value_fn=lambda data: data.state ), - SensorEntityDescription( - key=AnovaPrecisionCookerSensor.MODE, translation_key="mode" + AnovaSensorEntityDescription( + key="mode", translation_key="mode", value_fn=lambda data: data.mode ), - SensorEntityDescription( - key=AnovaPrecisionCookerSensor.TARGET_TEMPERATURE, + AnovaSensorEntityDescription( + key="target_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, icon="mdi:thermometer", translation_key="target_temperature", + value_fn=lambda data: data.target_temperature, ), - SensorEntityDescription( - key=AnovaPrecisionCookerSensor.COOK_TIME_REMAINING, + AnovaSensorEntityDescription( + key="cook_time_remaining", native_unit_of_measurement=UnitOfTime.SECONDS, icon="mdi:clock-outline", translation_key="cook_time_remaining", + device_class=SensorDeviceClass.DURATION, + value_fn=lambda data: data.cook_time_remaining, ), - SensorEntityDescription( - key=AnovaPrecisionCookerSensor.HEATER_TEMPERATURE, + AnovaSensorEntityDescription( + key="heater_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, icon="mdi:thermometer", translation_key="heater_temperature", + value_fn=lambda data: data.heater_temperature, ), - SensorEntityDescription( - key=AnovaPrecisionCookerSensor.TRIAC_TEMPERATURE, + AnovaSensorEntityDescription( + key="triac_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, icon="mdi:thermometer", translation_key="triac_temperature", + value_fn=lambda data: data.triac_temperature, ), - SensorEntityDescription( - key=AnovaPrecisionCookerSensor.WATER_TEMPERATURE, + AnovaSensorEntityDescription( + key="water_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, icon="mdi:thermometer", translation_key="water_temperature", + value_fn=lambda data: data.water_temperature, ), ] @@ -91,7 +117,9 @@ async def async_setup_entry( class AnovaSensor(AnovaDescriptionEntity, SensorEntity): """A sensor using Anova coordinator.""" + entity_description: AnovaSensorEntityDescription + @property def native_value(self) -> StateType: """Return the state.""" - return self.coordinator.data["sensors"][self.entity_description.key] + return self.entity_description.value_fn(self.coordinator.data.sensor) diff --git a/requirements_all.txt b/requirements_all.txt index 3d2a33c7c96b..be3c22bb7625 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -339,7 +339,7 @@ androidtvremote2==0.0.7 anel_pwrctrl-homeassistant==0.0.1.dev2 # homeassistant.components.anova -anova-wifi==0.8.0 +anova-wifi==0.9.0 # homeassistant.components.anthemav anthemav==1.4.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b90e6ff1f429..fd56a9e8f404 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -311,7 +311,7 @@ androidtv[async]==0.0.70 androidtvremote2==0.0.7 # homeassistant.components.anova -anova-wifi==0.8.0 +anova-wifi==0.9.0 # homeassistant.components.anthemav anthemav==1.4.1 diff --git a/tests/components/anova/__init__.py b/tests/components/anova/__init__.py index e0e31c84b7bf..5bcb84cb9744 100644 --- a/tests/components/anova/__init__.py +++ b/tests/components/anova/__init__.py @@ -3,11 +3,7 @@ from __future__ import annotations from unittest.mock import patch -from anova_wifi import ( - AnovaPrecisionCooker, - AnovaPrecisionCookerBinarySensor, - AnovaPrecisionCookerSensor, -) +from anova_wifi import AnovaPrecisionCooker, APCUpdate, APCUpdateBinary, APCUpdateSensor from homeassistant.components.anova.const import DOMAIN from homeassistant.config_entries import ConfigEntry @@ -20,26 +16,12 @@ DEVICE_UNIQUE_ID = "abc123def" CONF_INPUT = {CONF_USERNAME: "sample@gmail.com", CONF_PASSWORD: "sample"} -ONLINE_UPDATE = { - "sensors": { - AnovaPrecisionCookerSensor.COOK_TIME: 0, - AnovaPrecisionCookerSensor.MODE: "Low water", - AnovaPrecisionCookerSensor.STATE: "No state", - AnovaPrecisionCookerSensor.TARGET_TEMPERATURE: 23.33, - AnovaPrecisionCookerSensor.COOK_TIME_REMAINING: 0, - AnovaPrecisionCookerSensor.FIRMWARE_VERSION: "2.2.0", - AnovaPrecisionCookerSensor.HEATER_TEMPERATURE: 20.87, - AnovaPrecisionCookerSensor.TRIAC_TEMPERATURE: 21.79, - AnovaPrecisionCookerSensor.WATER_TEMPERATURE: 21.33, - }, - "binary_sensors": { - AnovaPrecisionCookerBinarySensor.COOKING: False, - AnovaPrecisionCookerBinarySensor.DEVICE_SAFE: True, - AnovaPrecisionCookerBinarySensor.WATER_LEAK: False, - AnovaPrecisionCookerBinarySensor.WATER_LEVEL_CRITICAL: True, - AnovaPrecisionCookerBinarySensor.WATER_TEMP_TOO_HIGH: False, - }, -} +ONLINE_UPDATE = APCUpdate( + sensor=APCUpdateSensor( + 0, "Low water", "No state", 23.33, 0, "2.2.0", 20.87, 21.79, 21.33 + ), + binary_sensor=APCUpdateBinary(False, False, False, True, False, True, False), +) def create_entry(hass: HomeAssistant, device_id: str = DEVICE_UNIQUE_ID) -> ConfigEntry: From 979569966912b6b2a9da516463523892a9246546 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 1 May 2023 16:17:01 +0200 Subject: [PATCH 119/822] Fix db_url issue in SQL (#92324) * db_url fix * Add test * assert entry.options --- homeassistant/components/sql/__init__.py | 10 ++ homeassistant/components/sql/config_flow.py | 27 ++--- homeassistant/components/sql/sensor.py | 9 +- homeassistant/components/sql/util.py | 14 +++ tests/components/sql/test_config_flow.py | 105 ++++++++++++++++++++ 5 files changed, 145 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/sql/__init__.py b/homeassistant/components/sql/__init__.py index 92b640580eb9..dd5480450e28 100644 --- a/homeassistant/components/sql/__init__.py +++ b/homeassistant/components/sql/__init__.py @@ -1,6 +1,8 @@ """The sql component.""" from __future__ import annotations +import logging + import voluptuous as vol from homeassistant.components.recorder import CONF_DB_URL, get_instance @@ -24,6 +26,9 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import CONF_COLUMN_NAME, CONF_QUERY, DOMAIN, PLATFORMS +from .util import redact_credentials + +_LOGGER = logging.getLogger(__name__) def validate_sql_select(value: str) -> str: @@ -85,6 +90,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up SQL from a config entry.""" + _LOGGER.debug( + "Comparing %s and %s", + redact_credentials(entry.options.get(CONF_DB_URL)), + redact_credentials(get_instance(hass).db_url), + ) if entry.options.get(CONF_DB_URL) == get_instance(hass).db_url: remove_configured_db_url_if_not_needed(hass, entry) diff --git a/homeassistant/components/sql/config_flow.py b/homeassistant/components/sql/config_flow.py index 23be7735c3d7..7cbcbe73efaf 100644 --- a/homeassistant/components/sql/config_flow.py +++ b/homeassistant/components/sql/config_flow.py @@ -11,7 +11,7 @@ from sqlalchemy.orm import Session, scoped_session, sessionmaker import voluptuous as vol from homeassistant import config_entries -from homeassistant.components.recorder import CONF_DB_URL +from homeassistant.components.recorder import CONF_DB_URL, get_instance from homeassistant.const import CONF_NAME, CONF_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult @@ -159,13 +159,9 @@ class SQLConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) -class SQLOptionsFlowHandler(config_entries.OptionsFlow): +class SQLOptionsFlowHandler(config_entries.OptionsFlowWithConfigEntry): """Handle SQL options.""" - def __init__(self, entry: config_entries.ConfigEntry) -> None: - """Initialize SQL options flow.""" - self.entry = entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -177,7 +173,7 @@ class SQLOptionsFlowHandler(config_entries.OptionsFlow): db_url = user_input.get(CONF_DB_URL) query = user_input[CONF_QUERY] column = user_input[CONF_COLUMN_NAME] - name = self.entry.options.get(CONF_NAME, self.entry.title) + name = self.options.get(CONF_NAME, self.config_entry.title) try: validate_sql_select(query) @@ -193,21 +189,26 @@ class SQLOptionsFlowHandler(config_entries.OptionsFlow): except ValueError: errors["query"] = "query_invalid" else: - new_user_input = user_input - if new_user_input.get(CONF_DB_URL) and db_url == db_url_for_validation: - new_user_input.pop(CONF_DB_URL) + recorder_db = get_instance(self.hass).db_url + _LOGGER.debug( + "db_url: %s, resolved db_url: %s, recorder: %s", + db_url, + db_url_for_validation, + recorder_db, + ) + if db_url and db_url_for_validation == recorder_db: + user_input.pop(CONF_DB_URL) return self.async_create_entry( - title="", data={ CONF_NAME: name, - **new_user_input, + **user_input, }, ) return self.async_show_form( step_id="init", data_schema=self.add_suggested_values_to_schema( - OPTIONS_SCHEMA, user_input or self.entry.options + OPTIONS_SCHEMA, user_input or self.options ), errors=errors, description_placeholders=description_placeholders, diff --git a/homeassistant/components/sql/sensor.py b/homeassistant/components/sql/sensor.py index eb0e9c9c46b7..2a8ea80580bb 100644 --- a/homeassistant/components/sql/sensor.py +++ b/homeassistant/components/sql/sensor.py @@ -42,20 +42,15 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import CONF_COLUMN_NAME, CONF_QUERY, DB_URL_RE, DOMAIN +from .const import CONF_COLUMN_NAME, CONF_QUERY, DOMAIN from .models import SQLData -from .util import resolve_db_url +from .util import redact_credentials, resolve_db_url _LOGGER = logging.getLogger(__name__) _SQL_LAMBDA_CACHE: LRUCache = LRUCache(1000) -def redact_credentials(data: str) -> str: - """Redact credentials from string data.""" - return DB_URL_RE.sub("//****:****@", data) - - async def async_setup_platform( hass: HomeAssistant, config: ConfigType, diff --git a/homeassistant/components/sql/util.py b/homeassistant/components/sql/util.py index 81d8cd9900cb..3dd0990b2419 100644 --- a/homeassistant/components/sql/util.py +++ b/homeassistant/components/sql/util.py @@ -1,12 +1,26 @@ """Utils for sql.""" from __future__ import annotations +import logging + from homeassistant.components.recorder import get_instance from homeassistant.core import HomeAssistant +from .const import DB_URL_RE + +_LOGGER = logging.getLogger(__name__) + + +def redact_credentials(data: str | None) -> str: + """Redact credentials from string data.""" + if not data: + return "none" + return DB_URL_RE.sub("//****:****@", data) + def resolve_db_url(hass: HomeAssistant, db_url: str | None) -> str: """Return the db_url provided if not empty, otherwise return the recorder db_url.""" + _LOGGER.debug("db_url: %s", redact_credentials(db_url)) if db_url and not db_url.isspace(): return db_url return get_instance(hass).db_url diff --git a/tests/components/sql/test_config_flow.py b/tests/components/sql/test_config_flow.py index 086469afb292..a8e590a9760f 100644 --- a/tests/components/sql/test_config_flow.py +++ b/tests/components/sql/test_config_flow.py @@ -464,3 +464,108 @@ async def test_options_flow_db_url_empty( "column": "size", "unit_of_measurement": "MiB", } + + +async def test_full_flow_not_recorder_db( + recorder_mock: Recorder, hass: HomeAssistant +) -> None: + """Test full config flow with not using recorder db.""" + 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.sql.async_setup_entry", + return_value=True, + ), patch( + "homeassistant.components.sql.config_flow.sqlalchemy.create_engine", + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "db_url": "sqlite://path/to/db.db", + "name": "Get Value", + "query": "SELECT 5 as value", + "column": "value", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "Get Value" + assert result2["options"] == { + "name": "Get Value", + "db_url": "sqlite://path/to/db.db", + "query": "SELECT 5 as value", + "column": "value", + "unit_of_measurement": None, + "value_template": None, + } + + entry = hass.config_entries.async_entries(DOMAIN)[0] + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + + with patch( + "homeassistant.components.sql.async_setup_entry", + return_value=True, + ), patch( + "homeassistant.components.sql.config_flow.sqlalchemy.create_engine", + ): + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "query": "SELECT 5 as value", + "db_url": "sqlite://path/to/db.db", + "column": "value", + "unit_of_measurement": "MiB", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + "name": "Get Value", + "db_url": "sqlite://path/to/db.db", + "query": "SELECT 5 as value", + "column": "value", + "unit_of_measurement": "MiB", + } + + # Need to test same again to mitigate issue with db_url removal + result = await hass.config_entries.options.async_init(entry.entry_id) + with patch( + "homeassistant.components.sql.config_flow.sqlalchemy.create_engine", + ): + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "query": "SELECT 5 as value", + "db_url": "sqlite://path/to/db.db", + "column": "value", + "unit_of_measurement": "MB", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + "name": "Get Value", + "db_url": "sqlite://path/to/db.db", + "query": "SELECT 5 as value", + "column": "value", + "unit_of_measurement": "MB", + } + + assert entry.options == { + "name": "Get Value", + "db_url": "sqlite://path/to/db.db", + "query": "SELECT 5 as value", + "column": "value", + "unit_of_measurement": "MB", + } From adaae46178a1abcd0e01ca4432cf9f0f24b9b6fa Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 1 May 2023 10:20:37 -0400 Subject: [PATCH 120/822] Add voip configuration url (#92326) --- homeassistant/components/voip/devices.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/voip/devices.py b/homeassistant/components/voip/devices.py index 8b691e855e31..5da7a97ec248 100644 --- a/homeassistant/components/voip/devices.py +++ b/homeassistant/components/voip/devices.py @@ -139,6 +139,7 @@ class VoIPDevices: manufacturer=manuf, model=model, sw_version=fw_version, + configuration_url=f"http://{call_info.caller_ip}", ) voip_device = self.devices[voip_id] = VoIPDevice( voip_id=voip_id, From 2bc2c4a651a3eb21c3b2aa0fd0e921eba59e4baf Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Mon, 1 May 2023 12:32:40 -0400 Subject: [PATCH 121/822] Don't poll ZHA electrical measurement sensors unnecessarily (#92330) --- homeassistant/components/zha/sensor.py | 22 +++++++++++++++------- tests/components/zha/test_sensor.py | 10 ++++++++-- tests/components/zha/zha_devices_list.py | 18 +++++++++--------- 3 files changed, 32 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index dda9412b56f4..52c1f6a5b193 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -248,13 +248,16 @@ class Battery(Sensor): return state_attrs -@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT) +@MULTI_MATCH( + cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT, + stop_on_match_group=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT, + models={"VZM31-SN", "SP 234", "outletv4"}, +) class ElectricalMeasurement(Sensor): """Active power measurement.""" SENSOR_ATTR = "active_power" _attr_device_class: SensorDeviceClass = SensorDeviceClass.POWER - _attr_should_poll = True # BaseZhaEntity defaults to False _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT _attr_name: str = "Active power" _attr_native_unit_of_measurement: str = UnitOfPower.WATT @@ -284,6 +287,16 @@ class ElectricalMeasurement(Sensor): return round(value, self._decimals) return round(value) + +@MULTI_MATCH( + cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT, + stop_on_match_group=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT, +) +class PolledElectricalMeasurement(ElectricalMeasurement): + """Polled active power measurement.""" + + _attr_should_poll = True # BaseZhaEntity defaults to False + async def async_update(self) -> None: """Retrieve latest state.""" if not self.available: @@ -299,7 +312,6 @@ class ElectricalMeasurementApparentPower( SENSOR_ATTR = "apparent_power" _attr_device_class: SensorDeviceClass = SensorDeviceClass.APPARENT_POWER - _attr_should_poll = False # Poll indirectly by ElectricalMeasurementSensor _attr_name: str = "Apparent power" _attr_native_unit_of_measurement = UnitOfApparentPower.VOLT_AMPERE _div_mul_prefix = "ac_power" @@ -311,7 +323,6 @@ class ElectricalMeasurementRMSCurrent(ElectricalMeasurement, id_suffix="rms_curr SENSOR_ATTR = "rms_current" _attr_device_class: SensorDeviceClass = SensorDeviceClass.CURRENT - _attr_should_poll = False # Poll indirectly by ElectricalMeasurementSensor _attr_name: str = "RMS current" _attr_native_unit_of_measurement = UnitOfElectricCurrent.AMPERE _div_mul_prefix = "ac_current" @@ -323,7 +334,6 @@ class ElectricalMeasurementRMSVoltage(ElectricalMeasurement, id_suffix="rms_volt SENSOR_ATTR = "rms_voltage" _attr_device_class: SensorDeviceClass = SensorDeviceClass.VOLTAGE - _attr_should_poll = False # Poll indirectly by ElectricalMeasurementSensor _attr_name: str = "RMS voltage" _attr_native_unit_of_measurement = UnitOfElectricPotential.VOLT _div_mul_prefix = "ac_voltage" @@ -335,7 +345,6 @@ class ElectricalMeasurementFrequency(ElectricalMeasurement, id_suffix="ac_freque SENSOR_ATTR = "ac_frequency" _attr_device_class: SensorDeviceClass = SensorDeviceClass.FREQUENCY - _attr_should_poll = False # Poll indirectly by ElectricalMeasurementSensor _attr_name: str = "AC frequency" _attr_native_unit_of_measurement = UnitOfFrequency.HERTZ _div_mul_prefix = "ac_frequency" @@ -347,7 +356,6 @@ class ElectricalMeasurementPowerFactor(ElectricalMeasurement, id_suffix="power_f SENSOR_ATTR = "power_factor" _attr_device_class: SensorDeviceClass = SensorDeviceClass.POWER_FACTOR - _attr_should_poll = False # Poll indirectly by ElectricalMeasurementSensor _attr_name: str = "Power factor" _attr_native_unit_of_measurement = PERCENTAGE diff --git a/tests/components/zha/test_sensor.py b/tests/components/zha/test_sensor.py index 4e2d22651781..83799147bbe5 100644 --- a/tests/components/zha/test_sensor.py +++ b/tests/components/zha/test_sensor.py @@ -1,6 +1,6 @@ """Test ZHA sensor.""" import math -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest import zigpy.profiles.zha @@ -586,6 +586,10 @@ async def test_temp_uom( assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == uom +@patch( + "zigpy.zcl.ClusterPersistingListener", + MagicMock(), +) async def test_electrical_measurement_init( hass: HomeAssistant, zigpy_device_mock, @@ -605,7 +609,9 @@ async def test_electrical_measurement_init( ) cluster = zigpy_device.endpoints[1].in_clusters[cluster_id] zha_device = await zha_device_joined(zigpy_device) - entity_id = await find_entity_id(Platform.SENSOR, zha_device, hass) + entity_id = await find_entity_id( + Platform.SENSOR, zha_device, hass, qualifier="active_power" + ) # allow traffic to flow through the gateway and devices await async_enable_traffic(hass, [zha_device]) diff --git a/tests/components/zha/zha_devices_list.py b/tests/components/zha/zha_devices_list.py index a45d02a3aa26..4ccf7323148b 100644 --- a/tests/components/zha/zha_devices_list.py +++ b/tests/components/zha/zha_devices_list.py @@ -195,7 +195,7 @@ DEVICES = [ }, ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], - DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", + DEV_SIG_ENT_MAP_CLASS: "PolledElectricalMeasurement", DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_active_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-apparent_power"): { @@ -2080,7 +2080,7 @@ DEVICES = [ }, ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], - DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", + DEV_SIG_ENT_MAP_CLASS: "PolledElectricalMeasurement", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_active_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_voltage"): { @@ -2155,7 +2155,7 @@ DEVICES = [ }, ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], - DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", + DEV_SIG_ENT_MAP_CLASS: "PolledElectricalMeasurement", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_active_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-apparent_power"): { @@ -3633,7 +3633,7 @@ DEVICES = [ }, ("sensor", "00:11:22:33:44:55:66:77-3-2820"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], - DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", + DEV_SIG_ENT_MAP_CLASS: "PolledElectricalMeasurement", DEV_SIG_ENT_MAP_ID: ( "sensor.osram_lightify_rt_tunable_white_active_power" ), @@ -4039,7 +4039,7 @@ DEVICES = [ }, ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], - DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", + DEV_SIG_ENT_MAP_CLASS: "PolledElectricalMeasurement", DEV_SIG_ENT_MAP_ID: "sensor.securifi_ltd_unk_model_active_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-apparent_power"): { @@ -4167,7 +4167,7 @@ DEVICES = [ }, ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], - DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", + DEV_SIG_ENT_MAP_CLASS: "PolledElectricalMeasurement", DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_active_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-apparent_power"): { @@ -4293,7 +4293,7 @@ DEVICES = [ }, ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], - DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", + DEV_SIG_ENT_MAP_CLASS: "PolledElectricalMeasurement", DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_rm3250zb_active_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-apparent_power"): { @@ -4378,7 +4378,7 @@ DEVICES = [ }, ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], - DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", + DEV_SIG_ENT_MAP_CLASS: "PolledElectricalMeasurement", DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_active_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-apparent_power"): { @@ -4468,7 +4468,7 @@ DEVICES = [ }, ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], - DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", + DEV_SIG_ENT_MAP_CLASS: "PolledElectricalMeasurement", DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_active_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-apparent_power"): { From 324df197d196fe9d484e5e5b5bdbc0ac4af70934 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 1 May 2023 11:33:52 -0500 Subject: [PATCH 122/822] Avoid starting ONVIF PullPoint if the camera reports its unsupported (#92333) --- homeassistant/components/onvif/device.py | 23 +++++++++++++++++--- homeassistant/components/onvif/event.py | 8 ++++--- homeassistant/components/onvif/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 28 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/onvif/device.py b/homeassistant/components/onvif/device.py index 574780311657..adb6fa89059e 100644 --- a/homeassistant/components/onvif/device.py +++ b/homeassistant/components/onvif/device.py @@ -6,6 +6,7 @@ from contextlib import suppress import datetime as dt import os import time +from typing import Any from httpx import RequestError import onvif @@ -55,6 +56,7 @@ class ONVIFDevice: self.info: DeviceInfo = DeviceInfo() self.capabilities: Capabilities = Capabilities() + self.onvif_capabilities: dict[str, Any] | None = None self.profiles: list[Profile] = [] self.max_resolution: int = 0 self.platforms: list[Platform] = [] @@ -98,6 +100,10 @@ class ONVIFDevice: # Get all device info await self.device.update_xaddrs() + + # Get device capabilities + self.onvif_capabilities = await self.device.get_capabilities() + await self.async_check_date_and_time() # Create event manager @@ -107,8 +113,9 @@ class ONVIFDevice: # Fetch basic device info and capabilities self.info = await self.async_get_device_info() LOGGER.debug("Camera %s info = %s", self.name, self.info) - self.capabilities = await self.async_get_capabilities() - LOGGER.debug("Camera %s capabilities = %s", self.name, self.capabilities) + + # Check profiles before capabilities since the camera may be slow to respond + # once the event manager is started in async_get_capabilities. self.profiles = await self.async_get_profiles() LOGGER.debug("Camera %s profiles = %s", self.name, self.profiles) @@ -116,6 +123,9 @@ class ONVIFDevice: if not self.profiles: raise ONVIFError("No camera profiles found") + self.capabilities = await self.async_get_capabilities() + LOGGER.debug("Camera %s capabilities = %s", self.name, self.capabilities) + if self.capabilities.ptz: self.device.create_ptz_service() @@ -299,7 +309,14 @@ class ONVIFDevice: events = False with suppress(*GET_CAPABILITIES_EXCEPTIONS, XMLParseError): - events = await self.events.async_start() + onvif_capabilities = self.onvif_capabilities or {} + pull_point_support = onvif_capabilities.get("Events", {}).get( + "WSPullPointSupport" + ) + LOGGER.debug("%s: WSPullPointSupport: %s", self.name, pull_point_support) + events = await self.events.async_start( + pull_point_support is not False, True + ) return Capabilities(snapshot, events, ptz, imaging) diff --git a/homeassistant/components/onvif/event.py b/homeassistant/components/onvif/event.py index a3b11fab1965..4c2efabf61ac 100644 --- a/homeassistant/components/onvif/event.py +++ b/homeassistant/components/onvif/event.py @@ -123,11 +123,13 @@ class EventManager: if not self._listeners: self.pullpoint_manager.async_cancel_pull_messages() - async def async_start(self) -> bool: + async def async_start(self, try_pullpoint: bool, try_webhook: bool) -> bool: """Start polling events.""" # Always start pull point first, since it will populate the event list - event_via_pull_point = await self.pullpoint_manager.async_start() - events_via_webhook = await self.webhook_manager.async_start() + event_via_pull_point = ( + try_pullpoint and await self.pullpoint_manager.async_start() + ) + events_via_webhook = try_webhook and await self.webhook_manager.async_start() return events_via_webhook or event_via_pull_point async def async_stop(self) -> None: diff --git a/homeassistant/components/onvif/manifest.json b/homeassistant/components/onvif/manifest.json index 41d5164452fa..17e7f1f0f29d 100644 --- a/homeassistant/components/onvif/manifest.json +++ b/homeassistant/components/onvif/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/onvif", "iot_class": "local_push", "loggers": ["onvif", "wsdiscovery", "zeep"], - "requirements": ["onvif-zeep-async==1.3.0", "WSDiscovery==2.0.0"] + "requirements": ["onvif-zeep-async==1.3.1", "WSDiscovery==2.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index be3c22bb7625..778924850238 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1258,7 +1258,7 @@ ondilo==0.2.0 onkyo-eiscp==1.2.7 # homeassistant.components.onvif -onvif-zeep-async==1.3.0 +onvif-zeep-async==1.3.1 # homeassistant.components.opengarage open-garage==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fd56a9e8f404..0429b82ae9e7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -945,7 +945,7 @@ omnilogic==0.4.5 ondilo==0.2.0 # homeassistant.components.onvif -onvif-zeep-async==1.3.0 +onvif-zeep-async==1.3.1 # homeassistant.components.opengarage open-garage==0.2.0 From d66056cfabd2d921208d2912254cb04f4d5c1b08 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 1 May 2023 21:11:40 +0200 Subject: [PATCH 123/822] Improve fritz generic typing (#92327) --- homeassistant/components/fritz/common.py | 3 +-- homeassistant/components/fritz/switch.py | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/fritz/common.py b/homeassistant/components/fritz/common.py index 821b53f7e123..818030965f95 100644 --- a/homeassistant/components/fritz/common.py +++ b/homeassistant/components/fritz/common.py @@ -1049,10 +1049,9 @@ class FritzEntityDescription(EntityDescription, FritzRequireKeysMixin): """Fritz entity base description.""" -class FritzBoxBaseCoordinatorEntity(update_coordinator.CoordinatorEntity): +class FritzBoxBaseCoordinatorEntity(update_coordinator.CoordinatorEntity[AvmWrapper]): """Fritz host coordinator entity base class.""" - coordinator: AvmWrapper entity_description: FritzEntityDescription _attr_has_entity_name = True diff --git a/homeassistant/components/fritz/switch.py b/homeassistant/components/fritz/switch.py index c8a7952ae2b5..5b8c40485306 100644 --- a/homeassistant/components/fritz/switch.py +++ b/homeassistant/components/fritz/switch.py @@ -256,10 +256,9 @@ async def async_setup_entry( ) -class FritzBoxBaseCoordinatorSwitch(CoordinatorEntity, SwitchEntity): +class FritzBoxBaseCoordinatorSwitch(CoordinatorEntity[AvmWrapper], SwitchEntity): """Fritz switch coordinator base class.""" - coordinator: AvmWrapper entity_description: SwitchEntityDescription _attr_has_entity_name = True From b1d6f3afc06707d9eea6db2f67a416336b7884e2 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Mon, 1 May 2023 15:42:27 -0500 Subject: [PATCH 124/822] Allow configuring SIP port in VoIP (#92210) Co-authored-by: Franck Nijhof --- homeassistant/components/voip/__init__.py | 37 +++++++++---- homeassistant/components/voip/config_flow.py | 53 +++++++++++++++++-- homeassistant/components/voip/const.py | 2 + homeassistant/components/voip/strings.json | 9 ++++ homeassistant/components/voip/voip.py | 9 ++++ tests/components/voip/conftest.py | 7 ++- tests/components/voip/test_config_flow.py | 41 ++++++++++++++- tests/components/voip/test_sip.py | 55 ++++++++++++++++++++ tests/components/voip/test_voip.py | 19 +++++-- 9 files changed, 210 insertions(+), 22 deletions(-) create mode 100644 tests/components/voip/test_sip.py diff --git a/homeassistant/components/voip/__init__.py b/homeassistant/components/voip/__init__.py index 9ea202e3b572..f29705cf41b1 100644 --- a/homeassistant/components/voip/__init__.py +++ b/homeassistant/components/voip/__init__.py @@ -14,7 +14,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from .const import DOMAIN +from .const import CONF_SIP_PORT, DOMAIN from .devices import VoIPDevices from .voip import HassVoipDatagramProtocol @@ -39,6 +39,7 @@ class DomainData: """Domain data.""" transport: asyncio.DatagramTransport + protocol: HassVoipDatagramProtocol devices: VoIPDevices @@ -56,41 +57,57 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry, data={**entry.data, "user": voip_user.id} ) + sip_port = entry.options.get(CONF_SIP_PORT, SIP_PORT) devices = VoIPDevices(hass, entry) devices.async_setup() - transport = await _create_sip_server( + transport, protocol = await _create_sip_server( hass, lambda: HassVoipDatagramProtocol(hass, devices), + sip_port, ) - _LOGGER.debug("Listening for VoIP calls on port %s", SIP_PORT) + _LOGGER.debug("Listening for VoIP calls on port %s", sip_port) - hass.data[DOMAIN] = DomainData(transport, devices) + hass.data[DOMAIN] = DomainData(transport, protocol, devices) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(update_listener)) + return True +async def update_listener(hass: HomeAssistant, entry: ConfigEntry): + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) + + async def _create_sip_server( hass: HomeAssistant, protocol_factory: Callable[ [], asyncio.DatagramProtocol, ], -) -> asyncio.DatagramTransport: - transport, _protocol = await hass.loop.create_datagram_endpoint( + sip_port: int, +) -> tuple[asyncio.DatagramTransport, HassVoipDatagramProtocol]: + transport, protocol = await hass.loop.create_datagram_endpoint( protocol_factory, - local_addr=(_IP_WILDCARD, SIP_PORT), + local_addr=(_IP_WILDCARD, sip_port), ) - return transport + if not isinstance(protocol, HassVoipDatagramProtocol): + raise TypeError(f"Expected HassVoipDatagramProtocol, got {protocol}") + + return transport, protocol async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload VoIP.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - _LOGGER.debug("Shut down VoIP server") - hass.data.pop(DOMAIN).transport.close() + _LOGGER.debug("Shutting down VoIP server") + data = hass.data.pop(DOMAIN) + data.transport.close() + await data.protocol.wait_closed() + _LOGGER.debug("VoIP server shut down successfully") return unload_ok diff --git a/homeassistant/components/voip/config_flow.py b/homeassistant/components/voip/config_flow.py index 2c9649d911dc..3af15bd2c0b4 100644 --- a/homeassistant/components/voip/config_flow.py +++ b/homeassistant/components/voip/config_flow.py @@ -3,10 +3,15 @@ from __future__ import annotations from typing import Any -from homeassistant import config_entries -from homeassistant.data_entry_flow import FlowResult +from voip_utils import SIP_PORT +import voluptuous as vol -from .const import DOMAIN +from homeassistant import config_entries +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import config_validation as cv + +from .const import CONF_SIP_PORT, DOMAIN class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -22,9 +27,49 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_abort(reason="single_instance_allowed") if user_input is None: - return self.async_show_form(step_id="user") + return self.async_show_form( + step_id="user", + ) return self.async_create_entry( title="Voice over IP", data=user_input, ) + + @staticmethod + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> config_entries.OptionsFlow: + """Create the options flow.""" + return VoipOptionsFlowHandler(config_entry) + + +class VoipOptionsFlowHandler(config_entries.OptionsFlow): + """Handle VoIP options.""" + + def __init__(self, config_entry: config_entries.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 the options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Required( + CONF_SIP_PORT, + default=self.config_entry.options.get( + CONF_SIP_PORT, + SIP_PORT, + ), + ): cv.port + } + ), + ) diff --git a/homeassistant/components/voip/const.py b/homeassistant/components/voip/const.py index 8288297d8ef0..b4ee5d8ce7aa 100644 --- a/homeassistant/components/voip/const.py +++ b/homeassistant/components/voip/const.py @@ -11,3 +11,5 @@ RTP_AUDIO_SETTINGS = { "channels": CHANNELS, "sleep_ratio": 0.99, } + +CONF_SIP_PORT = "sip_port" diff --git a/homeassistant/components/voip/strings.json b/homeassistant/components/voip/strings.json index 83931d42c578..2bef9a18008e 100644 --- a/homeassistant/components/voip/strings.json +++ b/homeassistant/components/voip/strings.json @@ -28,5 +28,14 @@ } } } + }, + "options": { + "step": { + "init": { + "data": { + "sip_port": "SIP port" + } + } + } } } diff --git a/homeassistant/components/voip/voip.py b/homeassistant/components/voip/voip.py index 3fcc2336aa38..ddf40f5918ec 100644 --- a/homeassistant/components/voip/voip.py +++ b/homeassistant/components/voip/voip.py @@ -84,12 +84,21 @@ class HassVoipDatagramProtocol(VoipDatagramProtocol): ) self.hass = hass self.devices = devices + self._closed_event = asyncio.Event() def is_valid_call(self, call_info: CallInfo) -> bool: """Filter calls.""" device = self.devices.async_get_or_create(call_info) return device.async_allow_call(self.hass) + def connection_lost(self, exc): + """Signal wait_closed when transport is completely closed.""" + self.hass.loop.call_soon_threadsafe(self._closed_event.set) + + async def wait_closed(self) -> None: + """Wait for connection_lost to be called.""" + await self._closed_event.wait() + class PipelineRtpDatagramProtocol(RtpDatagramProtocol): """Run a voice assistant pipeline in a loop for a VoIP call.""" diff --git a/tests/components/voip/conftest.py b/tests/components/voip/conftest.py index 499d454cf4da..619a80d86c47 100644 --- a/tests/components/voip/conftest.py +++ b/tests/components/voip/conftest.py @@ -2,7 +2,7 @@ from __future__ import annotations -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, Mock, patch import pytest from voip_utils import CallInfo @@ -33,7 +33,10 @@ def config_entry(hass: HomeAssistant) -> MockConfigEntry: @pytest.fixture async def setup_voip(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: """Set up VoIP integration.""" - with patch("homeassistant.components.voip._create_sip_server", return_value=Mock()): + with patch( + "homeassistant.components.voip._create_sip_server", + return_value=(Mock(), AsyncMock()), + ): assert await async_setup_component(hass, DOMAIN, {}) assert config_entry.state == ConfigEntryState.LOADED yield diff --git a/tests/components/voip/test_config_flow.py b/tests/components/voip/test_config_flow.py index 9b3420775c24..f7b3595699ce 100644 --- a/tests/components/voip/test_config_flow.py +++ b/tests/components/voip/test_config_flow.py @@ -1,11 +1,13 @@ """Test VoIP config flow.""" from unittest.mock import patch -from homeassistant import config_entries +from homeassistant import config_entries, data_entry_flow from homeassistant.components import voip from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from tests.common import MockConfigEntry + async def test_form_user(hass: HomeAssistant) -> None: """Test user form config flow.""" @@ -40,3 +42,40 @@ async def test_single_instance( ) assert result["type"] == "abort" assert result["reason"] == "single_instance_allowed" + + +async def test_options_flow(hass: HomeAssistant) -> None: + """Test config flow options.""" + config_entry = MockConfigEntry( + domain=voip.DOMAIN, + data={}, + unique_id="1234", + ) + config_entry.add_to_hass(hass) + + assert config_entry.options == {} + + result = await hass.config_entries.options.async_init( + config_entry.entry_id, + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "init" + + # Default + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={}, + ) + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert config_entry.options == {"sip_port": 5060} + + # Manual + result = await hass.config_entries.options.async_init( + config_entry.entry_id, + ) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"sip_port": 5061}, + ) + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert config_entry.options == {"sip_port": 5061} diff --git a/tests/components/voip/test_sip.py b/tests/components/voip/test_sip.py new file mode 100644 index 000000000000..975b8f326d91 --- /dev/null +++ b/tests/components/voip/test_sip.py @@ -0,0 +1,55 @@ +"""Test SIP server.""" +import socket + +import pytest + +from homeassistant import config_entries +from homeassistant.components import voip +from homeassistant.core import HomeAssistant + + +async def test_create_sip_server(hass: HomeAssistant, socket_enabled) -> None: + """Tests starting/stopping SIP server.""" + result = await hass.config_entries.flow.async_init( + voip.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + entry = result["result"] + await hass.async_block_till_done() + + with pytest.raises(OSError), socket.socket( + socket.AF_INET, socket.SOCK_DGRAM + ) as sock: + # Server should have the port + sock.bind(("127.0.0.1", 5060)) + + # Configure different port + result = await hass.config_entries.options.async_init( + entry.entry_id, + ) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"sip_port": 5061}, + ) + await hass.async_block_till_done() + + # Server should be stopped now on 5060 + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + sock.bind(("127.0.0.1", 5060)) + + with pytest.raises(OSError), socket.socket( + socket.AF_INET, socket.SOCK_DGRAM + ) as sock: + # Server should now have the new port + sock.bind(("127.0.0.1", 5061)) + + # Shut down + await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + + # Server should be stopped + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + sock.bind(("127.0.0.1", 5061)) diff --git a/tests/components/voip/test_voip.py b/tests/components/voip/test_voip.py index 6ccfae904e8f..aec9122fae19 100644 --- a/tests/components/voip/test_voip.py +++ b/tests/components/voip/test_voip.py @@ -237,9 +237,15 @@ async def test_tts_timeout( ) ) - def send_audio(*args, **kwargs): + tone_bytes = bytes([1, 2, 3, 4]) + + def send_audio(audio_bytes, **kwargs): + if audio_bytes == tone_bytes: + # Not TTS + return + # Block here to force a timeout in _send_tts - time.sleep(1) + time.sleep(2) async def async_get_media_source_audio( hass: HomeAssistant, @@ -263,10 +269,13 @@ async def test_tts_timeout( hass.config.language, voip_device, Context(), - listening_tone_enabled=False, - processing_tone_enabled=False, - error_tone_enabled=False, + listening_tone_enabled=True, + processing_tone_enabled=True, + error_tone_enabled=True, ) + rtp_protocol._tone_bytes = tone_bytes + rtp_protocol._processing_bytes = tone_bytes + rtp_protocol._error_bytes = tone_bytes rtp_protocol.transport = Mock() rtp_protocol.send_audio = Mock(side_effect=send_audio) From 40896514eb8a525aa0d725538a13eb31fd49eec7 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Mon, 1 May 2023 15:43:27 -0500 Subject: [PATCH 125/822] Bump voip-utils to 0.0.6 (#92334) --- homeassistant/components/voip/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/voip/manifest.json b/homeassistant/components/voip/manifest.json index b9439ee682cd..2842e494e7ed 100644 --- a/homeassistant/components/voip/manifest.json +++ b/homeassistant/components/voip/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/voip", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["voip-utils==0.0.5"] + "requirements": ["voip-utils==0.0.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index 778924850238..b5afb635c086 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2585,7 +2585,7 @@ venstarcolortouch==0.19 vilfo-api-client==0.3.2 # homeassistant.components.voip -voip-utils==0.0.5 +voip-utils==0.0.6 # homeassistant.components.volkszaehler volkszaehler==0.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0429b82ae9e7..b0275a608b0f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1870,7 +1870,7 @@ venstarcolortouch==0.19 vilfo-api-client==0.3.2 # homeassistant.components.voip -voip-utils==0.0.5 +voip-utils==0.0.6 # homeassistant.components.volvooncall volvooncall==0.10.2 From a7088e767ee7519448829651cddc6eeb16f3b8e1 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 1 May 2023 22:46:38 +0200 Subject: [PATCH 126/822] Migrate unique IDs of Rituals Perfume Genie (#92342) * Migrate unique IDs of Rituals Perfume Genie * Fix doc string --- .../rituals_perfume_genie/__init__.py | 43 +++++++++++- .../rituals_perfume_genie/binary_sensor.py | 1 + .../rituals_perfume_genie/entity.py | 1 - .../rituals_perfume_genie/number.py | 1 + .../rituals_perfume_genie/select.py | 1 + .../rituals_perfume_genie/sensor.py | 4 ++ .../rituals_perfume_genie/switch.py | 1 + .../test_binary_sensor.py | 3 +- .../rituals_perfume_genie/test_init.py | 69 ++++++++++++++++++- .../rituals_perfume_genie/test_number.py | 3 +- .../rituals_perfume_genie/test_select.py | 3 +- .../rituals_perfume_genie/test_sensor.py | 16 ++--- .../rituals_perfume_genie/test_switch.py | 2 +- 13 files changed, 126 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/rituals_perfume_genie/__init__.py b/homeassistant/components/rituals_perfume_genie/__init__.py index b607a84b5d15..e0fac0abfcf5 100644 --- a/homeassistant/components/rituals_perfume_genie/__init__.py +++ b/homeassistant/components/rituals_perfume_genie/__init__.py @@ -2,12 +2,13 @@ import asyncio import aiohttp -from pyrituals import Account +from pyrituals import Account, Diffuser from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import ACCOUNT_HASH, DOMAIN @@ -32,6 +33,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except aiohttp.ClientError as err: raise ConfigEntryNotReady from err + # Migrate old unique_ids to the new format + async_migrate_entities_unique_ids(hass, entry, account_devices) + # Create a coordinator for each diffuser coordinators = { diffuser.hublot: RitualsDataUpdateCoordinator(hass, diffuser) @@ -59,3 +63,38 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok + + +@callback +def async_migrate_entities_unique_ids( + hass: HomeAssistant, config_entry: ConfigEntry, diffusers: list[Diffuser] +) -> None: + """Migrate unique_ids in the entity registry to the new format.""" + entity_registry = er.async_get(hass) + registry_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + + conversion: dict[tuple[str, str], str] = { + (Platform.BINARY_SENSOR, " Battery Charging"): "charging", + (Platform.NUMBER, " Perfume Amount"): "perfume_amount", + (Platform.SELECT, " Room Size"): "room_size_square_meter", + (Platform.SENSOR, " Battery"): "battery_percentage", + (Platform.SENSOR, " Fill"): "fill", + (Platform.SENSOR, " Perfume"): "perfume", + (Platform.SENSOR, " Wifi"): "wifi_percentage", + (Platform.SWITCH, ""): "is_on", + } + + for diffuser in diffusers: + for registry_entry in registry_entries: + if new_unique_id := conversion.get( + ( + registry_entry.domain, + registry_entry.unique_id.removeprefix(diffuser.hublot), + ) + ): + entity_registry.async_update_entity( + registry_entry.entity_id, + new_unique_id=f"{diffuser.hublot}-{new_unique_id}", + ) diff --git a/homeassistant/components/rituals_perfume_genie/binary_sensor.py b/homeassistant/components/rituals_perfume_genie/binary_sensor.py index 95d28c9797b9..d6c5eaeaca10 100644 --- a/homeassistant/components/rituals_perfume_genie/binary_sensor.py +++ b/homeassistant/components/rituals_perfume_genie/binary_sensor.py @@ -43,6 +43,7 @@ class DiffuserBatteryChargingBinarySensor(DiffuserEntity, BinarySensorEntity): def __init__(self, coordinator: RitualsDataUpdateCoordinator) -> None: """Initialize the battery charging binary sensor.""" super().__init__(coordinator, CHARGING_SUFFIX) + self._attr_unique_id = f"{coordinator.diffuser.hublot}-charging" @property def is_on(self) -> bool: diff --git a/homeassistant/components/rituals_perfume_genie/entity.py b/homeassistant/components/rituals_perfume_genie/entity.py index b5e016c20661..2cb1eccd4e9f 100644 --- a/homeassistant/components/rituals_perfume_genie/entity.py +++ b/homeassistant/components/rituals_perfume_genie/entity.py @@ -26,7 +26,6 @@ class DiffuserEntity(CoordinatorEntity[RitualsDataUpdateCoordinator]): hubname = coordinator.diffuser.name self._attr_name = f"{hubname}{entity_suffix}" - self._attr_unique_id = f"{hublot}{entity_suffix}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, hublot)}, manufacturer=MANUFACTURER, diff --git a/homeassistant/components/rituals_perfume_genie/number.py b/homeassistant/components/rituals_perfume_genie/number.py index 3da28f8b6f42..6d7eb53cb59b 100644 --- a/homeassistant/components/rituals_perfume_genie/number.py +++ b/homeassistant/components/rituals_perfume_genie/number.py @@ -40,6 +40,7 @@ class DiffuserPerfumeAmount(DiffuserEntity, NumberEntity): def __init__(self, coordinator: RitualsDataUpdateCoordinator) -> None: """Initialize the diffuser perfume amount number.""" super().__init__(coordinator, PERFUME_AMOUNT_SUFFIX) + self._attr_unique_id = f"{coordinator.diffuser.hublot}-perfume_amount" @property def native_value(self) -> int: diff --git a/homeassistant/components/rituals_perfume_genie/select.py b/homeassistant/components/rituals_perfume_genie/select.py index b40c30455777..a6ac10eadec5 100644 --- a/homeassistant/components/rituals_perfume_genie/select.py +++ b/homeassistant/components/rituals_perfume_genie/select.py @@ -43,6 +43,7 @@ class DiffuserRoomSize(DiffuserEntity, SelectEntity): self._attr_entity_registry_enabled_default = ( self.coordinator.diffuser.has_battery ) + self._attr_unique_id = f"{coordinator.diffuser.hublot}-room_size_square_meter" @property def current_option(self) -> str: diff --git a/homeassistant/components/rituals_perfume_genie/sensor.py b/homeassistant/components/rituals_perfume_genie/sensor.py index 1f301d3538d9..e2a1be63adf4 100644 --- a/homeassistant/components/rituals_perfume_genie/sensor.py +++ b/homeassistant/components/rituals_perfume_genie/sensor.py @@ -48,6 +48,7 @@ class DiffuserPerfumeSensor(DiffuserEntity, SensorEntity): def __init__(self, coordinator: RitualsDataUpdateCoordinator) -> None: """Initialize the perfume sensor.""" super().__init__(coordinator, PERFUME_SUFFIX) + self._attr_unique_id = f"{coordinator.diffuser.hublot}-perfume" @property def icon(self) -> str: @@ -68,6 +69,7 @@ class DiffuserFillSensor(DiffuserEntity, SensorEntity): def __init__(self, coordinator: RitualsDataUpdateCoordinator) -> None: """Initialize the fill sensor.""" super().__init__(coordinator, FILL_SUFFIX) + self._attr_unique_id = f"{coordinator.diffuser.hublot}-fill" @property def icon(self) -> str: @@ -92,6 +94,7 @@ class DiffuserBatterySensor(DiffuserEntity, SensorEntity): def __init__(self, coordinator: RitualsDataUpdateCoordinator) -> None: """Initialize the battery sensor.""" super().__init__(coordinator, BATTERY_SUFFIX) + self._attr_unique_id = f"{coordinator.diffuser.hublot}-battery_percentage" @property def native_value(self) -> int: @@ -108,6 +111,7 @@ class DiffuserWifiSensor(DiffuserEntity, SensorEntity): def __init__(self, coordinator: RitualsDataUpdateCoordinator) -> None: """Initialize the wifi sensor.""" super().__init__(coordinator, WIFI_SUFFIX) + self._attr_unique_id = f"{coordinator.diffuser.hublot}-wifi_percentage" @property def native_value(self) -> int: diff --git a/homeassistant/components/rituals_perfume_genie/switch.py b/homeassistant/components/rituals_perfume_genie/switch.py index 92513d9a83b5..180abb13576f 100644 --- a/homeassistant/components/rituals_perfume_genie/switch.py +++ b/homeassistant/components/rituals_perfume_genie/switch.py @@ -37,6 +37,7 @@ class DiffuserSwitch(DiffuserEntity, SwitchEntity): """Initialize the diffuser switch.""" super().__init__(coordinator, "") self._attr_is_on = self.coordinator.diffuser.is_on + self._attr_unique_id = f"{coordinator.diffuser.hublot}-is_on" async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" diff --git a/tests/components/rituals_perfume_genie/test_binary_sensor.py b/tests/components/rituals_perfume_genie/test_binary_sensor.py index ea4d8021ebaf..082725a8f997 100644 --- a/tests/components/rituals_perfume_genie/test_binary_sensor.py +++ b/tests/components/rituals_perfume_genie/test_binary_sensor.py @@ -1,6 +1,5 @@ """Tests for the Rituals Perfume Genie binary sensor platform.""" from homeassistant.components.binary_sensor import BinarySensorDeviceClass -from homeassistant.components.rituals_perfume_genie.binary_sensor import CHARGING_SUFFIX from homeassistant.const import ATTR_DEVICE_CLASS, STATE_ON, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -30,5 +29,5 @@ async def test_binary_sensors( entry = entity_registry.async_get("binary_sensor.genie_battery_charging") assert entry - assert entry.unique_id == f"{hublot}{CHARGING_SUFFIX}" + assert entry.unique_id == f"{hublot}-charging" assert entry.entity_category == EntityCategory.DIAGNOSTIC diff --git a/tests/components/rituals_perfume_genie/test_init.py b/tests/components/rituals_perfume_genie/test_init.py index 0f1c2a230d17..8766e99f5422 100644 --- a/tests/components/rituals_perfume_genie/test_init.py +++ b/tests/components/rituals_perfume_genie/test_init.py @@ -6,8 +6,13 @@ import aiohttp from homeassistant.components.rituals_perfume_genie.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er -from .common import init_integration, mock_config_entry +from .common import ( + init_integration, + mock_config_entry, + mock_diffuser_v1_battery_cartridge, +) async def test_config_entry_not_ready(hass: HomeAssistant) -> None: @@ -32,3 +37,65 @@ async def test_config_entry_unload(hass: HomeAssistant) -> None: assert config_entry.state is ConfigEntryState.NOT_LOADED assert config_entry.entry_id not in hass.data[DOMAIN] + + +async def test_entity_id_migration( + hass: HomeAssistant, entity_registry: er.RegistryEntry +) -> None: + """Test the migration of unique IDs on config entry setup.""" + config_entry = mock_config_entry(unique_id="binary_sensor_test_diffuser_v1") + + # Pre-create old style unique IDs + charging = entity_registry.async_get_or_create( + "binary_sensor", DOMAIN, "lot123v1 Battery Charging", config_entry=config_entry + ) + perfume_amount = entity_registry.async_get_or_create( + "number", DOMAIN, "lot123v1 Perfume Amount", config_entry=config_entry + ) + room_size = entity_registry.async_get_or_create( + "select", DOMAIN, "lot123v1 Room Size", config_entry=config_entry + ) + battery = entity_registry.async_get_or_create( + "sensor", DOMAIN, "lot123v1 Battery", config_entry=config_entry + ) + fill = entity_registry.async_get_or_create( + "sensor", DOMAIN, "lot123v1 Fill", config_entry=config_entry + ) + perfume = entity_registry.async_get_or_create( + "sensor", DOMAIN, "lot123v1 Perfume", config_entry=config_entry + ) + wifi = entity_registry.async_get_or_create( + "sensor", DOMAIN, "lot123v1 Wifi", config_entry=config_entry + ) + switch = entity_registry.async_get_or_create( + "switch", DOMAIN, "lot123v1", config_entry=config_entry + ) + + # Set up integration + diffuser = mock_diffuser_v1_battery_cartridge() + await init_integration(hass, config_entry, [diffuser]) + + # Check that old style unique IDs have been migrated + entry = entity_registry.async_get(charging.entity_id) + assert entry.unique_id == "lot123v1-charging" + + entry = entity_registry.async_get(perfume_amount.entity_id) + assert entry.unique_id == "lot123v1-perfume_amount" + + entry = entity_registry.async_get(room_size.entity_id) + assert entry.unique_id == "lot123v1-room_size_square_meter" + + entry = entity_registry.async_get(battery.entity_id) + assert entry.unique_id == "lot123v1-battery_percentage" + + entry = entity_registry.async_get(fill.entity_id) + assert entry.unique_id == "lot123v1-fill" + + entry = entity_registry.async_get(perfume.entity_id) + assert entry.unique_id == "lot123v1-perfume" + + entry = entity_registry.async_get(wifi.entity_id) + assert entry.unique_id == "lot123v1-wifi_percentage" + + entry = entity_registry.async_get(switch.entity_id) + assert entry.unique_id == "lot123v1-is_on" diff --git a/tests/components/rituals_perfume_genie/test_number.py b/tests/components/rituals_perfume_genie/test_number.py index 028ab40ed76b..550f09202ace 100644 --- a/tests/components/rituals_perfume_genie/test_number.py +++ b/tests/components/rituals_perfume_genie/test_number.py @@ -14,7 +14,6 @@ from homeassistant.components.number import ( from homeassistant.components.rituals_perfume_genie.number import ( MAX_PERFUME_AMOUNT, MIN_PERFUME_AMOUNT, - PERFUME_AMOUNT_SUFFIX, ) from homeassistant.const import ATTR_ENTITY_ID, ATTR_ICON from homeassistant.core import HomeAssistant @@ -46,7 +45,7 @@ async def test_number_entity( entry = entity_registry.async_get("number.genie_perfume_amount") assert entry - assert entry.unique_id == f"{diffuser.hublot}{PERFUME_AMOUNT_SUFFIX}" + assert entry.unique_id == f"{diffuser.hublot}-perfume_amount" async def test_set_number_value(hass: HomeAssistant) -> None: diff --git a/tests/components/rituals_perfume_genie/test_select.py b/tests/components/rituals_perfume_genie/test_select.py index 00147b9073c2..3153005d094c 100644 --- a/tests/components/rituals_perfume_genie/test_select.py +++ b/tests/components/rituals_perfume_genie/test_select.py @@ -2,7 +2,6 @@ import pytest from homeassistant.components.homeassistant import SERVICE_UPDATE_ENTITY -from homeassistant.components.rituals_perfume_genie.select import ROOM_SIZE_SUFFIX from homeassistant.components.select import ( ATTR_OPTION, ATTR_OPTIONS, @@ -38,7 +37,7 @@ async def test_select_entity( entry = entity_registry.async_get("select.genie_room_size") assert entry - assert entry.unique_id == f"{diffuser.hublot}{ROOM_SIZE_SUFFIX}" + assert entry.unique_id == f"{diffuser.hublot}-room_size_square_meter" assert entry.unit_of_measurement == AREA_SQUARE_METERS assert entry.entity_category == EntityCategory.CONFIG diff --git a/tests/components/rituals_perfume_genie/test_sensor.py b/tests/components/rituals_perfume_genie/test_sensor.py index 5573ddc6332d..dbbd83d1df31 100644 --- a/tests/components/rituals_perfume_genie/test_sensor.py +++ b/tests/components/rituals_perfume_genie/test_sensor.py @@ -1,11 +1,5 @@ """Tests for the Rituals Perfume Genie sensor platform.""" -from homeassistant.components.rituals_perfume_genie.sensor import ( - BATTERY_SUFFIX, - FILL_SUFFIX, - PERFUME_SUFFIX, - WIFI_SUFFIX, - SensorDeviceClass, -) +from homeassistant.components.rituals_perfume_genie.sensor import SensorDeviceClass from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ICON, @@ -40,7 +34,7 @@ async def test_sensors_diffuser_v1_battery_cartridge( entry = entity_registry.async_get("sensor.genie_perfume") assert entry - assert entry.unique_id == f"{hublot}{PERFUME_SUFFIX}" + assert entry.unique_id == f"{hublot}-perfume" state = hass.states.get("sensor.genie_fill") assert state @@ -49,7 +43,7 @@ async def test_sensors_diffuser_v1_battery_cartridge( entry = entity_registry.async_get("sensor.genie_fill") assert entry - assert entry.unique_id == f"{hublot}{FILL_SUFFIX}" + assert entry.unique_id == f"{hublot}-fill" state = hass.states.get("sensor.genie_battery") assert state @@ -59,7 +53,7 @@ async def test_sensors_diffuser_v1_battery_cartridge( entry = entity_registry.async_get("sensor.genie_battery") assert entry - assert entry.unique_id == f"{hublot}{BATTERY_SUFFIX}" + assert entry.unique_id == f"{hublot}-battery_percentage" assert entry.entity_category == EntityCategory.DIAGNOSTIC state = hass.states.get("sensor.genie_wifi") @@ -70,7 +64,7 @@ async def test_sensors_diffuser_v1_battery_cartridge( entry = entity_registry.async_get("sensor.genie_wifi") assert entry - assert entry.unique_id == f"{hublot}{WIFI_SUFFIX}" + assert entry.unique_id == f"{hublot}-wifi_percentage" assert entry.entity_category == EntityCategory.DIAGNOSTIC diff --git a/tests/components/rituals_perfume_genie/test_switch.py b/tests/components/rituals_perfume_genie/test_switch.py index 7f33e2381181..69c2dc019235 100644 --- a/tests/components/rituals_perfume_genie/test_switch.py +++ b/tests/components/rituals_perfume_genie/test_switch.py @@ -38,7 +38,7 @@ async def test_switch_entity( entry = entity_registry.async_get("switch.genie") assert entry - assert entry.unique_id == diffuser.hublot + assert entry.unique_id == f"{diffuser.hublot}-is_on" async def test_switch_handle_coordinator_update(hass: HomeAssistant) -> None: From a6d6d59c93cb8fe7b507fa4058a60f8dae320c57 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 1 May 2023 22:49:38 +0200 Subject: [PATCH 127/822] Update frontend to 20230501.0 (#92339) --- 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 e925e68b5731..a6eb045f61c6 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==20230428.0"] + "requirements": ["home-assistant-frontend==20230501.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 9a1b6654af12..20e776169bd7 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -25,7 +25,7 @@ ha-av==10.0.0 hass-nabucasa==0.66.2 hassil==1.0.6 home-assistant-bluetooth==1.10.0 -home-assistant-frontend==20230428.0 +home-assistant-frontend==20230501.0 home-assistant-intents==2023.4.26 httpx==0.24.0 ifaddr==0.1.7 diff --git a/requirements_all.txt b/requirements_all.txt index b5afb635c086..9d67cfcf7434 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -911,7 +911,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230428.0 +home-assistant-frontend==20230501.0 # homeassistant.components.conversation home-assistant-intents==2023.4.26 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b0275a608b0f..65be9ce45c23 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -700,7 +700,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230428.0 +home-assistant-frontend==20230501.0 # homeassistant.components.conversation home-assistant-intents==2023.4.26 From 5dd3209e42015358c8a64e0e9ae47da4da772407 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Mon, 1 May 2023 16:50:44 -0400 Subject: [PATCH 128/822] Add date and time platforms to core files (#92343) --- .core_files.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.core_files.yaml b/.core_files.yaml index 2c9bc22fd704..118346408f8f 100644 --- a/.core_files.yaml +++ b/.core_files.yaml @@ -20,6 +20,7 @@ base_platforms: &base_platforms - homeassistant/components/camera/** - homeassistant/components/climate/** - homeassistant/components/cover/** + - homeassistant/components/date/** - homeassistant/components/device_tracker/** - homeassistant/components/diagnostics/** - homeassistant/components/fan/** @@ -39,6 +40,7 @@ base_platforms: &base_platforms - homeassistant/components/stt/** - homeassistant/components/switch/** - homeassistant/components/text/** + - homeassistant/components/time/** - homeassistant/components/tts/** - homeassistant/components/update/** - homeassistant/components/vacuum/** From 86f8147782179b32387a4c76c2b5ec19840c6ffd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 1 May 2023 15:51:30 -0500 Subject: [PATCH 129/822] Bump zeroconf to 0.60.0 (#92335) --- 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 314a0c9ef78a..e5001c17e808 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.58.2"] + "requirements": ["zeroconf==0.60.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 20e776169bd7..7a5d8cdffc9e 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -52,7 +52,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtcvad==2.0.10 yarl==1.9.2 -zeroconf==0.58.2 +zeroconf==0.60.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 9d67cfcf7434..b0038703c088 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2703,7 +2703,7 @@ zamg==0.2.2 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.58.2 +zeroconf==0.60.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 65be9ce45c23..925255ff3fe0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1958,7 +1958,7 @@ youless-api==1.0.1 zamg==0.2.2 # homeassistant.components.zeroconf -zeroconf==0.58.2 +zeroconf==0.60.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From 2636a46a5f624617c2086ef146e7e747a6d87aa1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 May 2023 01:46:14 -0500 Subject: [PATCH 130/822] Bump ulid-transform to 0.7.2 (#92344) --- 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 7a5d8cdffc9e..cfac6ae32778 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -47,7 +47,7 @@ requests==2.29.0 scapy==2.5.0 sqlalchemy==2.0.12 typing-extensions>=4.5.0,<5.0 -ulid-transform==0.7.0 +ulid-transform==0.7.2 voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtcvad==2.0.10 diff --git a/pyproject.toml b/pyproject.toml index b2663ebe886b..5027e8ae0d7a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,7 +50,7 @@ dependencies = [ "pyyaml==6.0", "requests==2.29.0", "typing-extensions>=4.5.0,<5.0", - "ulid-transform==0.7.0", + "ulid-transform==0.7.2", "voluptuous==0.13.1", "voluptuous-serialize==2.6.0", "yarl==1.9.2", diff --git a/requirements.txt b/requirements.txt index d59a4f976521..75a5d6269ef9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,7 +24,7 @@ python-slugify==4.0.1 pyyaml==6.0 requests==2.29.0 typing-extensions>=4.5.0,<5.0 -ulid-transform==0.7.0 +ulid-transform==0.7.2 voluptuous==0.13.1 voluptuous-serialize==2.6.0 yarl==1.9.2 From c0fa078b0b671969d03f2e7b452170aff69276c4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 May 2023 05:17:01 -0500 Subject: [PATCH 131/822] Start onvif events later (#92354) --- homeassistant/components/onvif/device.py | 47 ++++++++++++++++++------ 1 file changed, 35 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/onvif/device.py b/homeassistant/components/onvif/device.py index adb6fa89059e..78e745645c57 100644 --- a/homeassistant/components/onvif/device.py +++ b/homeassistant/components/onvif/device.py @@ -100,6 +100,7 @@ class ONVIFDevice: # Get all device info await self.device.update_xaddrs() + LOGGER.debug("%s: xaddrs = %s", self.name, self.device.xaddrs) # Get device capabilities self.onvif_capabilities = await self.device.get_capabilities() @@ -112,10 +113,20 @@ class ONVIFDevice: # Fetch basic device info and capabilities self.info = await self.async_get_device_info() - LOGGER.debug("Camera %s info = %s", self.name, self.info) + LOGGER.debug("%s: camera info = %s", self.name, self.info) - # Check profiles before capabilities since the camera may be slow to respond - # once the event manager is started in async_get_capabilities. + # + # We need to check capabilities before profiles, because we need the data + # from capabilities to determine profiles correctly. + # + # We no longer initialize events in capabilities to avoid the problem + # where cameras become slow to respond for a bit after starting events, and + # instead we start events last and than update capabilities. + # + LOGGER.debug("%s: fetching initial capabilities", self.name) + self.capabilities = await self.async_get_capabilities() + + LOGGER.debug("%s: fetching profiles", self.name) self.profiles = await self.async_get_profiles() LOGGER.debug("Camera %s profiles = %s", self.name, self.profiles) @@ -123,10 +134,8 @@ class ONVIFDevice: if not self.profiles: raise ONVIFError("No camera profiles found") - self.capabilities = await self.async_get_capabilities() - LOGGER.debug("Camera %s capabilities = %s", self.name, self.capabilities) - if self.capabilities.ptz: + LOGGER.debug("%s: creating PTZ service", self.name) self.device.create_ptz_service() # Determine max resolution from profiles @@ -136,6 +145,12 @@ class ONVIFDevice: if profile.video.encoding == "H264" ) + # Start events last since some cameras become slow to respond + # for a bit after starting events + LOGGER.debug("%s: starting events", self.name) + self.capabilities.events = await self.async_start_events() + LOGGER.debug("Camera %s capabilities = %s", self.name, self.capabilities) + async def async_stop(self, event=None): """Shut it all down.""" if self.events: @@ -307,23 +322,31 @@ class ONVIFDevice: self.device.create_imaging_service() imaging = True - events = False + return Capabilities(snapshot=snapshot, ptz=ptz, imaging=imaging) + + async def async_start_events(self): + """Start the event handler.""" with suppress(*GET_CAPABILITIES_EXCEPTIONS, XMLParseError): onvif_capabilities = self.onvif_capabilities or {} pull_point_support = onvif_capabilities.get("Events", {}).get( "WSPullPointSupport" ) LOGGER.debug("%s: WSPullPointSupport: %s", self.name, pull_point_support) - events = await self.events.async_start( - pull_point_support is not False, True - ) + return await self.events.async_start(pull_point_support is not False, True) - return Capabilities(snapshot, events, ptz, imaging) + return False async def async_get_profiles(self) -> list[Profile]: """Obtain media profiles for this device.""" media_service = self.device.create_media_service() - result = await media_service.GetProfiles() + LOGGER.debug("%s: xaddr for media_service: %s", self.name, media_service.xaddr) + try: + result = await media_service.GetProfiles() + except GET_CAPABILITIES_EXCEPTIONS: + LOGGER.debug( + "%s: Could not get profiles from ONVIF device", self.name, exc_info=True + ) + raise profiles: list[Profile] = [] if not isinstance(result, list): From 9f7c085652f078fc12bdafc83cc1809250f552e4 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Tue, 2 May 2023 06:18:19 -0400 Subject: [PATCH 132/822] Clean up zwave_js.cover (#92353) --- homeassistant/components/zwave_js/cover.py | 66 ++++++++++--------- .../components/zwave_js/discovery.py | 14 ++-- 2 files changed, 43 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/zwave_js/cover.py b/homeassistant/components/zwave_js/cover.py index 4704718c8042..686a186a7cbc 100644 --- a/homeassistant/components/zwave_js/cover.py +++ b/homeassistant/components/zwave_js/cover.py @@ -51,7 +51,7 @@ async def async_setup_entry( entities: list[ZWaveBaseEntity] = [] if info.platform_hint == "motorized_barrier": entities.append(ZwaveMotorizedBarrier(config_entry, driver, info)) - elif info.platform_hint == "window_shutter_tilt": + elif info.platform_hint and info.platform_hint.endswith("tilt"): entities.append(ZWaveTiltCover(config_entry, driver, info)) else: entities.append(ZWaveCover(config_entry, driver, info)) @@ -99,6 +99,12 @@ def zwave_tilt_to_percent(value: int) -> int: class ZWaveCover(ZWaveBaseEntity, CoverEntity): """Representation of a Z-Wave Cover device.""" + _attr_supported_features = ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.SET_POSITION + ) + def __init__( self, config_entry: ConfigEntry, @@ -108,11 +114,20 @@ class ZWaveCover(ZWaveBaseEntity, CoverEntity): """Initialize a ZWaveCover entity.""" super().__init__(config_entry, driver, info) + self._stop_cover_value = ( + self.get_zwave_value(COVER_OPEN_PROPERTY) + or self.get_zwave_value(COVER_UP_PROPERTY) + or self.get_zwave_value(COVER_ON_PROPERTY) + ) + + if self._stop_cover_value: + self._attr_supported_features |= CoverEntityFeature.STOP + # Entity class attributes self._attr_device_class = CoverDeviceClass.WINDOW - if self.info.platform_hint in ("window_shutter", "window_shutter_tilt"): + if self.info.platform_hint and self.info.platform_hint.startswith("shutter"): self._attr_device_class = CoverDeviceClass.SHUTTER - if self.info.platform_hint == "window_blind": + if self.info.platform_hint and self.info.platform_hint.startswith("blind"): self._attr_device_class = CoverDeviceClass.BLIND @property @@ -153,28 +168,13 @@ class ZWaveCover(ZWaveBaseEntity, CoverEntity): async def async_stop_cover(self, **kwargs: Any) -> None: """Stop cover.""" - cover_property = ( - self.get_zwave_value(COVER_OPEN_PROPERTY) - or self.get_zwave_value(COVER_UP_PROPERTY) - or self.get_zwave_value(COVER_ON_PROPERTY) - ) - if cover_property: - # Stop the cover, will stop regardless of the actual direction of travel. - await self.info.node.async_set_value(cover_property, False) + assert self._stop_cover_value + # Stop the cover, will stop regardless of the actual direction of travel. + await self.info.node.async_set_value(self._stop_cover_value, False) class ZWaveTiltCover(ZWaveCover): - """Representation of a Z-Wave Cover device with tilt.""" - - _attr_supported_features = ( - CoverEntityFeature.OPEN - | CoverEntityFeature.CLOSE - | CoverEntityFeature.STOP - | CoverEntityFeature.SET_POSITION - | CoverEntityFeature.OPEN_TILT - | CoverEntityFeature.CLOSE_TILT - | CoverEntityFeature.SET_TILT_POSITION - ) + """Representation of a Z-Wave cover device with tilt.""" def __init__( self, @@ -184,8 +184,15 @@ class ZWaveTiltCover(ZWaveCover): ) -> None: """Initialize a ZWaveCover entity.""" super().__init__(config_entry, driver, info) - self.data_template = cast( + + self._current_tilt_value = cast( CoverTiltDataTemplate, self.info.platform_data_template + ).current_tilt_value(self.info.platform_data) + + self._attr_supported_features |= ( + CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.SET_TILT_POSITION ) @property @@ -194,19 +201,18 @@ class ZWaveTiltCover(ZWaveCover): None is unknown, 0 is closed, 100 is fully open. """ - value = self.data_template.current_tilt_value(self.info.platform_data) + value = self._current_tilt_value if value is None or value.value is None: return None return zwave_tilt_to_percent(int(value.value)) async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover tilt to a specific position.""" - tilt_value = self.data_template.current_tilt_value(self.info.platform_data) - if tilt_value: - await self.info.node.async_set_value( - tilt_value, - percent_to_zwave_tilt(kwargs[ATTR_TILT_POSITION]), - ) + assert self._current_tilt_value + await self.info.node.async_set_value( + self._current_tilt_value, + percent_to_zwave_tilt(kwargs[ATTR_TILT_POSITION]), + ) async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open the cover tilt.""" diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index b3255a76f7e3..a43482e3e906 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -347,7 +347,7 @@ DISCOVERY_SCHEMAS = [ # Fibaro Shutter Fibaro FGR222 ZWaveDiscoverySchema( platform=Platform.COVER, - hint="window_shutter_tilt", + hint="shutter_tilt", manufacturer_id={0x010F}, product_id={0x1000, 0x1001}, product_type={0x0301, 0x0302}, @@ -371,7 +371,7 @@ DISCOVERY_SCHEMAS = [ # Qubino flush shutter ZWaveDiscoverySchema( platform=Platform.COVER, - hint="window_shutter", + hint="shutter", manufacturer_id={0x0159}, product_id={0x0052, 0x0053}, product_type={0x0003}, @@ -380,7 +380,7 @@ DISCOVERY_SCHEMAS = [ # Graber/Bali/Spring Fashion Covers ZWaveDiscoverySchema( platform=Platform.COVER, - hint="window_blind", + hint="blind", manufacturer_id={0x026E}, product_id={0x5A31}, product_type={0x4353}, @@ -389,7 +389,7 @@ DISCOVERY_SCHEMAS = [ # iBlinds v2 window blind motor ZWaveDiscoverySchema( platform=Platform.COVER, - hint="window_blind", + hint="blind", manufacturer_id={0x0287}, product_id={0x000D}, product_type={0x0003}, @@ -398,7 +398,7 @@ DISCOVERY_SCHEMAS = [ # Merten 507801 Connect Roller Shutter ZWaveDiscoverySchema( platform=Platform.COVER, - hint="window_shutter", + hint="shutter", manufacturer_id={0x007A}, product_id={0x0001}, product_type={0x8003}, @@ -414,7 +414,7 @@ DISCOVERY_SCHEMAS = [ # Disable endpoint 2, as it has no practical function. CC: Switch_Multilevel ZWaveDiscoverySchema( platform=Platform.COVER, - hint="window_shutter", + hint="shutter", manufacturer_id={0x007A}, product_id={0x0001}, product_type={0x8003}, @@ -807,7 +807,7 @@ DISCOVERY_SCHEMAS = [ # window coverings ZWaveDiscoverySchema( platform=Platform.COVER, - hint="window_cover", + hint="cover", device_class_generic={"Multilevel Switch"}, device_class_specific={ "Motor Control Class A", From a9d992c2def8c3da7e97a3100efc8e3b3f5e8531 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 May 2023 05:19:27 -0500 Subject: [PATCH 133/822] Add support for visitor detections to onvif (#92350) --- homeassistant/components/onvif/parsers.py | 25 +++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/homeassistant/components/onvif/parsers.py b/homeassistant/components/onvif/parsers.py index 443254e125a0..abb1f114ce52 100644 --- a/homeassistant/components/onvif/parsers.py +++ b/homeassistant/components/onvif/parsers.py @@ -401,6 +401,31 @@ async def async_parse_face_detector(uid: str, msg) -> Event | None: 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. + + Topic: tns1:RuleEngine/MyRuleDetector/Visitor + """ + try: + video_source = "" + for source in msg.Message._value_1.Source.SimpleItem: + if source.Name == "Source": + video_source = source.Value + + return Event( + f"{uid}_{msg.Topic._value_1}_{video_source}", + "Visitor Detection", + "binary_sensor", + "occupancy", + None, + msg.Message._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: From cc4e741cfa21973cae47a0726e83ec2e51f8ba97 Mon Sep 17 00:00:00 2001 From: John Pettitt Date: Tue, 2 May 2023 07:50:34 -0700 Subject: [PATCH 134/822] Increase default timeout in sense (#90556) Co-authored-by: J. Nick Koston --- homeassistant/components/sense/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sense/const.py b/homeassistant/components/sense/const.py index 049b86e10645..cfe1a12a24f8 100644 --- a/homeassistant/components/sense/const.py +++ b/homeassistant/components/sense/const.py @@ -10,7 +10,7 @@ from sense_energy import ( ) DOMAIN = "sense" -DEFAULT_TIMEOUT = 10 +DEFAULT_TIMEOUT = 30 ACTIVE_UPDATE_RATE = 60 DEFAULT_NAME = "Sense" SENSE_DATA = "sense_data" From e3c16e634bb78afb2cfbab8268246f91b9ed5d91 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 2 May 2023 22:08:09 +0200 Subject: [PATCH 135/822] Allow exposing entities not in the entity registry to assistants (#92363) --- homeassistant/components/alexa/config.py | 3 +- homeassistant/components/alexa/handlers.py | 2 +- homeassistant/components/alexa/messages.py | 4 +- homeassistant/components/alexa/smart_home.py | 2 +- .../components/alexa/smart_home_http.py | 2 +- .../components/alexa/state_report.py | 2 +- .../components/cloud/alexa_config.py | 8 +- .../components/cloud/google_config.py | 30 +- .../components/conversation/__init__.py | 6 +- .../components/conversation/default_agent.py | 17 +- .../components/google_assistant/helpers.py | 16 +- .../components/google_assistant/http.py | 2 +- .../google_assistant/report_state.py | 4 +- .../components/google_assistant/smart_home.py | 6 +- .../components/homeassistant/__init__.py | 2 +- .../homeassistant/exposed_entities.py | 289 +++++++++++++----- .../components/switch_as_x/__init__.py | 2 +- .../components/switch_as_x/entity.py | 8 +- tests/components/alexa/test_smart_home.py | 21 +- tests/components/camera/test_init.py | 1 + tests/components/cast/test_media_player.py | 4 + tests/components/climate/test_recorder.py | 1 + tests/components/cloud/test_alexa_config.py | 46 ++- tests/components/cloud/test_client.py | 6 +- tests/components/cloud/test_google_config.py | 44 ++- .../color_extractor/test_service.py | 6 + tests/components/conversation/__init__.py | 6 +- .../conversation/test_default_agent.py | 2 +- tests/components/conversation/test_init.py | 6 +- tests/components/demo/conftest.py | 11 + tests/components/emulated_kasa/test_init.py | 8 + tests/components/fan/test_recorder.py | 1 + tests/components/google_assistant/__init__.py | 2 +- .../google_assistant/test_diagnostics.py | 1 + .../google_assistant/test_helpers.py | 6 +- .../components/google_assistant/test_http.py | 9 +- .../google_assistant/test_smart_home.py | 4 +- tests/components/group/conftest.py | 11 + tests/components/group/test_recorder.py | 7 + .../homeassistant/test_exposed_entities.py | 145 +++++++-- tests/components/homekit/test_config_flow.py | 1 + tests/components/homekit/test_diagnostics.py | 1 + tests/components/homekit/test_homekit.py | 1 + tests/components/homekit/test_init.py | 1 + .../components/homekit/test_type_triggers.py | 1 + tests/components/light/test_recorder.py | 1 + tests/components/number/test_recorder.py | 1 + tests/components/select/test_recorder.py | 1 + tests/components/switch/test_light.py | 8 + tests/components/switch_as_x/conftest.py | 9 + tests/components/switch_as_x/test_init.py | 8 +- tests/components/text/test_recorder.py | 1 + 52 files changed, 563 insertions(+), 224 deletions(-) diff --git a/homeassistant/components/alexa/config.py b/homeassistant/components/alexa/config.py index cdbea2ca346f..f1c4ad729c69 100644 --- a/homeassistant/components/alexa/config.py +++ b/homeassistant/components/alexa/config.py @@ -84,8 +84,7 @@ class AbstractConfig(ABC): unsub_func() self._unsub_proactive_report = None - @callback - def should_expose(self, entity_id): + async def should_expose(self, entity_id): """If an entity should be exposed.""" return False diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index eb23b09627e6..ee9ef61787be 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -103,7 +103,7 @@ async def async_api_discovery( discovery_endpoints = [ alexa_entity.serialize_discovery() for alexa_entity in async_get_entities(hass, config) - if config.should_expose(alexa_entity.entity_id) + if await config.should_expose(alexa_entity.entity_id) ] return directive.response( diff --git a/homeassistant/components/alexa/messages.py b/homeassistant/components/alexa/messages.py index 4dd154ea11f2..7aa929abf2ce 100644 --- a/homeassistant/components/alexa/messages.py +++ b/homeassistant/components/alexa/messages.py @@ -30,7 +30,7 @@ class AlexaDirective: self.entity = self.entity_id = self.endpoint = self.instance = None - def load_entity(self, hass, config): + async def load_entity(self, hass, config): """Set attributes related to the entity for this request. Sets these attributes when self.has_endpoint is True: @@ -49,7 +49,7 @@ class AlexaDirective: self.entity_id = _endpoint_id.replace("#", ".") self.entity = hass.states.get(self.entity_id) - if not self.entity or not config.should_expose(self.entity_id): + if not self.entity or not await config.should_expose(self.entity_id): raise AlexaInvalidEndpointError(_endpoint_id) self.endpoint = ENTITY_ADAPTERS[self.entity.domain](hass, config, self.entity) diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py index 242295078774..6c2da5c01c10 100644 --- a/homeassistant/components/alexa/smart_home.py +++ b/homeassistant/components/alexa/smart_home.py @@ -34,7 +34,7 @@ async def async_handle_message(hass, config, request, context=None, enabled=True await config.set_authorized(True) if directive.has_endpoint: - directive.load_entity(hass, config) + await directive.load_entity(hass, config) funct_ref = HANDLERS.get((directive.namespace, directive.name)) if funct_ref: diff --git a/homeassistant/components/alexa/smart_home_http.py b/homeassistant/components/alexa/smart_home_http.py index 9be7381adb60..5c7dd4d14026 100644 --- a/homeassistant/components/alexa/smart_home_http.py +++ b/homeassistant/components/alexa/smart_home_http.py @@ -60,7 +60,7 @@ class AlexaConfig(AbstractConfig): """Return an identifier for the user that represents this config.""" return "" - def should_expose(self, entity_id): + async def should_expose(self, entity_id): """If an entity should be exposed.""" if not self._config[CONF_FILTER].empty_filter: return self._config[CONF_FILTER](entity_id) diff --git a/homeassistant/components/alexa/state_report.py b/homeassistant/components/alexa/state_report.py index a189c364c02d..b9e1426bbc1d 100644 --- a/homeassistant/components/alexa/state_report.py +++ b/homeassistant/components/alexa/state_report.py @@ -64,7 +64,7 @@ async def async_enable_proactive_mode(hass, smart_home_config): if new_state.domain not in ENTITY_ADAPTERS: return - if not smart_home_config.should_expose(changed_entity): + if not await smart_home_config.should_expose(changed_entity): _LOGGER.debug("Not exposing %s because filtered by config", changed_entity) return diff --git a/homeassistant/components/cloud/alexa_config.py b/homeassistant/components/cloud/alexa_config.py index 9c691ebed557..06d6589204ba 100644 --- a/homeassistant/components/cloud/alexa_config.py +++ b/homeassistant/components/cloud/alexa_config.py @@ -257,14 +257,14 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): and entity_supported(self.hass, entity_id) ) - def should_expose(self, entity_id): + async def should_expose(self, entity_id): """If an entity should be exposed.""" if not self._config[CONF_FILTER].empty_filter: if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: return False return self._config[CONF_FILTER](entity_id) - return async_should_expose(self.hass, CLOUD_ALEXA, entity_id) + return await async_should_expose(self.hass, CLOUD_ALEXA, entity_id) @callback def async_invalidate_access_token(self): @@ -423,7 +423,7 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): is_enabled = self.enabled for entity in alexa_entities.async_get_entities(self.hass, self): - if is_enabled and self.should_expose(entity.entity_id): + if is_enabled and await self.should_expose(entity.entity_id): to_update.append(entity.entity_id) else: to_remove.append(entity.entity_id) @@ -482,7 +482,7 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): entity_id = event.data["entity_id"] - if not self.should_expose(entity_id): + if not await self.should_expose(entity_id): return action = event.data["action"] diff --git a/homeassistant/components/cloud/google_config.py b/homeassistant/components/cloud/google_config.py index 29b9c62ea1db..4c8ebfbd9e98 100644 --- a/homeassistant/components/cloud/google_config.py +++ b/homeassistant/components/cloud/google_config.py @@ -222,9 +222,9 @@ class CloudGoogleConfig(AbstractConfig): self._handle_device_registry_updated, ) - def should_expose(self, state): + async def should_expose(self, state): """If a state object should be exposed.""" - return self._should_expose_entity_id(state.entity_id) + return await self._should_expose_entity_id(state.entity_id) def _should_expose_legacy(self, entity_id): """If an entity ID should be exposed.""" @@ -258,14 +258,14 @@ class CloudGoogleConfig(AbstractConfig): and _supported_legacy(self.hass, entity_id) ) - def _should_expose_entity_id(self, entity_id): + async def _should_expose_entity_id(self, entity_id): """If an entity should be exposed.""" if not self._config[CONF_FILTER].empty_filter: if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: return False return self._config[CONF_FILTER](entity_id) - return async_should_expose(self.hass, CLOUD_GOOGLE, entity_id) + return await async_should_expose(self.hass, CLOUD_GOOGLE, entity_id) @property def agent_user_id(self): @@ -358,8 +358,7 @@ class CloudGoogleConfig(AbstractConfig): """Handle updated preferences.""" self.async_schedule_google_sync_all() - @callback - def _handle_entity_registry_updated(self, event: Event) -> None: + async def _handle_entity_registry_updated(self, event: Event) -> None: """Handle when entity registry updated.""" if ( not self.enabled @@ -376,13 +375,12 @@ class CloudGoogleConfig(AbstractConfig): entity_id = event.data["entity_id"] - if not self._should_expose_entity_id(entity_id): + if not await self._should_expose_entity_id(entity_id): return self.async_schedule_google_sync_all() - @callback - def _handle_device_registry_updated(self, event: Event) -> None: + async def _handle_device_registry_updated(self, event: Event) -> None: """Handle when device registry updated.""" if ( not self.enabled @@ -396,13 +394,15 @@ class CloudGoogleConfig(AbstractConfig): return # Check if any exposed entity uses the device area - if not any( - entity_entry.area_id is None - and self._should_expose_entity_id(entity_entry.entity_id) - for entity_entry in er.async_entries_for_device( - er.async_get(self.hass), event.data["device_id"] - ) + used = False + for entity_entry in er.async_entries_for_device( + er.async_get(self.hass), event.data["device_id"] ): + if entity_entry.area_id is None and await self._should_expose_entity_id( + entity_entry.entity_id + ): + used = True + if not used: return self.async_schedule_google_sync_all() diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index f156acfd568c..b27a6ebee02d 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -94,7 +94,7 @@ CONFIG_SCHEMA = vol.Schema( def _get_agent_manager(hass: HomeAssistant) -> AgentManager: """Get the active agent.""" manager = AgentManager(hass) - manager.async_setup() + hass.async_create_task(manager.async_setup()) return manager @@ -393,9 +393,9 @@ class AgentManager: self._agents: dict[str, AbstractConversationAgent] = {} self._builtin_agent_init_lock = asyncio.Lock() - def async_setup(self) -> None: + async def async_setup(self) -> None: """Set up the conversation agents.""" - async_setup_default_agent(self.hass) + await async_setup_default_agent(self.hass) async def async_get_agent( self, agent_id: str | None = None diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index d347140af2e2..d57de76f5e0f 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -73,23 +73,20 @@ def _get_language_variations(language: str) -> Iterable[str]: yield lang -@core.callback -def async_setup(hass: core.HomeAssistant) -> None: +async def async_setup(hass: core.HomeAssistant) -> None: """Set up entity registry listener for the default agent.""" entity_registry = er.async_get(hass) for entity_id in entity_registry.entities: - async_should_expose(hass, DOMAIN, entity_id) + await async_should_expose(hass, DOMAIN, entity_id) - @core.callback - def async_handle_entity_registry_changed(event: core.Event) -> None: + async def async_handle_entity_registry_changed(event: core.Event) -> None: """Set expose flag on newly created entities.""" if event.data["action"] == "create": - async_should_expose(hass, DOMAIN, event.data["entity_id"]) + await async_should_expose(hass, DOMAIN, event.data["entity_id"]) hass.bus.async_listen( er.EVENT_ENTITY_REGISTRY_UPDATED, async_handle_entity_registry_changed, - run_immediately=True, ) @@ -157,7 +154,7 @@ class DefaultAgent(AbstractConversationAgent): conversation_id, ) - slot_lists = self._make_slot_lists() + slot_lists = await self._make_slot_lists() result = await self.hass.async_add_executor_job( self._recognize, @@ -486,7 +483,7 @@ class DefaultAgent(AbstractConversationAgent): """Handle updated preferences.""" self._slot_lists = None - def _make_slot_lists(self) -> dict[str, SlotList]: + async def _make_slot_lists(self) -> dict[str, SlotList]: """Create slot lists with areas and entity names/aliases.""" if self._slot_lists is not None: return self._slot_lists @@ -496,7 +493,7 @@ class DefaultAgent(AbstractConversationAgent): entities = [ entity for entity in entity_registry.entities.values() - if async_should_expose(self.hass, DOMAIN, entity.entity_id) + if await async_should_expose(self.hass, DOMAIN, entity.entity_id) ] devices = dr.async_get(self.hass) diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index e194242df91a..d192b2514dea 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -175,7 +175,7 @@ class AbstractConfig(ABC): """Get agent user ID from context.""" @abstractmethod - def should_expose(self, state) -> bool: + async def should_expose(self, state) -> bool: """Return if entity should be exposed.""" def should_2fa(self, state): @@ -535,16 +535,14 @@ class GoogleEntity: ] return self._traits - @callback - def should_expose(self): + async def should_expose(self): """If entity should be exposed.""" - return self.config.should_expose(self.state) + return await self.config.should_expose(self.state) - @callback - def should_expose_local(self) -> bool: + async def should_expose_local(self) -> bool: """Return if the entity should be exposed locally.""" return ( - self.should_expose() + await self.should_expose() and get_google_type( self.state.domain, self.state.attributes.get(ATTR_DEVICE_CLASS) ) @@ -587,7 +585,7 @@ class GoogleEntity: trait.might_2fa(domain, features, device_class) for trait in self.traits() ) - def sync_serialize(self, agent_user_id, instance_uuid): + async def sync_serialize(self, agent_user_id, instance_uuid): """Serialize entity for a SYNC response. https://developers.google.com/actions/smarthome/create-app#actiondevicessync @@ -623,7 +621,7 @@ class GoogleEntity: device["name"]["nicknames"].extend(entity_entry.aliases) # Add local SDK info if enabled - if self.config.is_local_sdk_active and self.should_expose_local(): + if self.config.is_local_sdk_active and await self.should_expose_local(): device["otherDeviceIds"] = [{"deviceId": self.entity_id}] device["customData"] = { "webhookId": self.config.get_local_webhook_id(agent_user_id), diff --git a/homeassistant/components/google_assistant/http.py b/homeassistant/components/google_assistant/http.py index 84d5e4a3364b..4aadc9c40027 100644 --- a/homeassistant/components/google_assistant/http.py +++ b/homeassistant/components/google_assistant/http.py @@ -111,7 +111,7 @@ class GoogleConfig(AbstractConfig): """Return if states should be proactively reported.""" return self._config.get(CONF_REPORT_STATE) - def should_expose(self, state) -> bool: + async def should_expose(self, state) -> bool: """Return if entity should be exposed.""" expose_by_default = self._config.get(CONF_EXPOSE_BY_DEFAULT) exposed_domains = self._config.get(CONF_EXPOSED_DOMAINS) diff --git a/homeassistant/components/google_assistant/report_state.py b/homeassistant/components/google_assistant/report_state.py index 737b54c8b1e9..c0a65cbfa7a8 100644 --- a/homeassistant/components/google_assistant/report_state.py +++ b/homeassistant/components/google_assistant/report_state.py @@ -63,7 +63,7 @@ def async_enable_report_state(hass: HomeAssistant, google_config: AbstractConfig if not new_state: return - if not google_config.should_expose(new_state): + if not await google_config.should_expose(new_state): return entity = GoogleEntity(hass, google_config, new_state) @@ -115,7 +115,7 @@ def async_enable_report_state(hass: HomeAssistant, google_config: AbstractConfig checker = await create_checker(hass, DOMAIN, extra_significant_check) for entity in async_get_entities(hass, google_config): - if not entity.should_expose(): + if not await entity.should_expose(): continue try: diff --git a/homeassistant/components/google_assistant/smart_home.py b/homeassistant/components/google_assistant/smart_home.py index 1b1b443baac0..798743a447d5 100644 --- a/homeassistant/components/google_assistant/smart_home.py +++ b/homeassistant/components/google_assistant/smart_home.py @@ -87,11 +87,11 @@ async def async_devices_sync_response(hass, config, agent_user_id): devices = [] for entity in entities: - if not entity.should_expose(): + if not await entity.should_expose(): continue try: - devices.append(entity.sync_serialize(agent_user_id, instance_uuid)) + devices.append(await entity.sync_serialize(agent_user_id, instance_uuid)) except Exception: # pylint: disable=broad-except _LOGGER.exception("Error serializing %s", entity.entity_id) @@ -318,7 +318,7 @@ async def async_devices_reachable( "devices": [ entity.reachable_device_serialize() for entity in async_get_entities(hass, data.config) - if entity.entity_id in google_ids and entity.should_expose_local() + if entity.entity_id in google_ids and await entity.should_expose_local() ] } diff --git a/homeassistant/components/homeassistant/__init__.py b/homeassistant/components/homeassistant/__init__.py index 987a4317ba84..45646b72b7f3 100644 --- a/homeassistant/components/homeassistant/__init__.py +++ b/homeassistant/components/homeassistant/__init__.py @@ -343,7 +343,7 @@ async def async_setup(hass: ha.HomeAssistant, config: ConfigType) -> bool: # no ) exposed_entities = ExposedEntities(hass) - await exposed_entities.async_initialize() + await exposed_entities.async_load() hass.data[DATA_EXPOSED_ENTITIES] = exposed_entities return True diff --git a/homeassistant/components/homeassistant/exposed_entities.py b/homeassistant/components/homeassistant/exposed_entities.py index b246c26e91ca..9217e073fe46 100644 --- a/homeassistant/components/homeassistant/exposed_entities.py +++ b/homeassistant/components/homeassistant/exposed_entities.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable, Mapping import dataclasses +from itertools import chain from typing import Any import voluptuous as vol @@ -14,6 +15,11 @@ from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES from homeassistant.core import HomeAssistant, callback, split_entity_id from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.collection import ( + IDManager, + SerializedStorageCollection, + StorageCollection, +) from homeassistant.helpers.entity import get_device_class from homeassistant.helpers.storage import Store @@ -77,25 +83,58 @@ class AssistantPreferences: return {"expose_new": self.expose_new} -class ExposedEntities: - """Control assistant settings.""" +@dataclasses.dataclass(frozen=True) +class ExposedEntity: + """An exposed entity without a unique_id.""" + + assistants: dict[str, dict[str, Any]] + + def to_json(self, entity_id: str) -> dict[str, Any]: + """Return a JSON serializable representation for storage.""" + return { + "assistants": self.assistants, + "id": entity_id, + } + + +class SerializedExposedEntities(SerializedStorageCollection): + """Serialized exposed entities storage storage collection.""" + + assistants: dict[str, dict[str, Any]] + + +class ExposedEntitiesIDManager(IDManager): + """ID manager for tags.""" + + def generate_id(self, suggestion: str) -> str: + """Generate an ID.""" + assert not self.has_id(suggestion) + return suggestion + + +class ExposedEntities(StorageCollection[ExposedEntity, SerializedExposedEntities]): + """Control assistant settings. + + Settings for entities without a unique_id are stored in the store. + Settings for entities with a unique_id are stored in the entity registry. + """ _assistants: dict[str, AssistantPreferences] def __init__(self, hass: HomeAssistant) -> None: """Initialize.""" - self._hass = hass - self._listeners: dict[str, list[Callable[[], None]]] = {} - self._store: Store[dict[str, dict[str, dict[str, Any]]]] = Store( - hass, STORAGE_VERSION, STORAGE_KEY + super().__init__( + Store(hass, STORAGE_VERSION, STORAGE_KEY), ExposedEntitiesIDManager() ) + self._listeners: dict[str, list[Callable[[], None]]] = {} - async def async_initialize(self) -> None: + async def async_load(self) -> None: """Finish initializing.""" - websocket_api.async_register_command(self._hass, ws_expose_entity) - websocket_api.async_register_command(self._hass, ws_expose_new_entities_get) - websocket_api.async_register_command(self._hass, ws_expose_new_entities_set) - await self.async_load() + await super().async_load() + websocket_api.async_register_command(self.hass, ws_expose_entity) + websocket_api.async_register_command(self.hass, ws_expose_new_entities_get) + websocket_api.async_register_command(self.hass, ws_expose_new_entities_set) + websocket_api.async_register_command(self.hass, ws_list_exposed_entities) @callback def async_listen_entity_updates( @@ -104,17 +143,18 @@ class ExposedEntities: """Listen for updates to entity expose settings.""" self._listeners.setdefault(assistant, []).append(listener) - @callback - def async_expose_entity( + async def async_expose_entity( self, assistant: str, entity_id: str, should_expose: bool ) -> None: """Expose an entity to an assistant. Notify listeners if expose flag was changed. """ - entity_registry = er.async_get(self._hass) + entity_registry = er.async_get(self.hass) if not (registry_entry := entity_registry.async_get(entity_id)): - raise HomeAssistantError("Unknown entity") + return await self._async_expose_legacy_entity( + assistant, entity_id, should_expose + ) assistant_options: Mapping[str, Any] if ( @@ -129,6 +169,34 @@ class ExposedEntities: for listener in self._listeners.get(assistant, []): listener() + async def _async_expose_legacy_entity( + self, assistant: str, entity_id: str, should_expose: bool + ) -> None: + """Expose an entity to an assistant. + + Notify listeners if expose flag was changed. + """ + if ( + (exposed_entity := self.data.get(entity_id)) + and (assistant_options := exposed_entity.assistants.get(assistant, {})) + and assistant_options.get("should_expose") == should_expose + ): + return + + if exposed_entity: + await self.async_update_item( + entity_id, {"assistants": {assistant: {"should_expose": should_expose}}} + ) + else: + await self.async_create_item( + { + "entity_id": entity_id, + "assistants": {assistant: {"should_expose": should_expose}}, + } + ) + for listener in self._listeners.get(assistant, []): + listener() + @callback def async_get_expose_new_entities(self, assistant: str) -> bool: """Check if new entities are exposed to an assistant.""" @@ -147,9 +215,14 @@ class ExposedEntities: self, assistant: str ) -> dict[str, Mapping[str, Any]]: """Get all entity expose settings for an assistant.""" - entity_registry = er.async_get(self._hass) + entity_registry = er.async_get(self.hass) result: dict[str, Mapping[str, Any]] = {} + options: Mapping | None + for entity_id, exposed_entity in self.data.items(): + if options := exposed_entity.assistants.get(assistant): + result[entity_id] = options + for entity_id, entry in entity_registry.entities.items(): if options := entry.options.get(assistant): result[entity_id] = options @@ -159,31 +232,33 @@ class ExposedEntities: @callback def async_get_entity_settings(self, entity_id: str) -> dict[str, Mapping[str, Any]]: """Get assistant expose settings for an entity.""" - entity_registry = er.async_get(self._hass) + entity_registry = er.async_get(self.hass) result: dict[str, Mapping[str, Any]] = {} - if not (registry_entry := entity_registry.async_get(entity_id)): + assistant_settings: Mapping + if registry_entry := entity_registry.async_get(entity_id): + assistant_settings = registry_entry.options + elif exposed_entity := self.data.get(entity_id): + assistant_settings = exposed_entity.assistants + else: raise HomeAssistantError("Unknown entity") for assistant in KNOWN_ASSISTANTS: - if options := registry_entry.options.get(assistant): + if options := assistant_settings.get(assistant): result[assistant] = options return result - @callback - def async_should_expose(self, assistant: str, entity_id: str) -> bool: + async def async_should_expose(self, assistant: str, entity_id: str) -> bool: """Return True if an entity should be exposed to an assistant.""" should_expose: bool if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: return False - entity_registry = er.async_get(self._hass) + entity_registry = er.async_get(self.hass) if not (registry_entry := entity_registry.async_get(entity_id)): - # Entities which are not in the entity registry are not exposed - return False - + return await self._async_should_expose_legacy_entity(assistant, entity_id) if assistant in registry_entry.options: if "should_expose" in registry_entry.options[assistant]: should_expose = registry_entry.options[assistant]["should_expose"] @@ -202,11 +277,43 @@ class ExposedEntities: return should_expose + async def _async_should_expose_legacy_entity( + self, assistant: str, entity_id: str + ) -> bool: + """Return True if an entity should be exposed to an assistant.""" + should_expose: bool + + if ( + exposed_entity := self.data.get(entity_id) + ) and assistant in exposed_entity.assistants: + if "should_expose" in exposed_entity.assistants[assistant]: + should_expose = exposed_entity.assistants[assistant]["should_expose"] + return should_expose + + if self.async_get_expose_new_entities(assistant): + should_expose = self._is_default_exposed(entity_id, None) + else: + should_expose = False + + if exposed_entity: + await self.async_update_item( + entity_id, {"assistants": {assistant: {"should_expose": should_expose}}} + ) + else: + await self.async_create_item( + { + "entity_id": entity_id, + "assistants": {assistant: {"should_expose": should_expose}}, + } + ) + + return should_expose + def _is_default_exposed( - self, entity_id: str, registry_entry: er.RegistryEntry + self, entity_id: str, registry_entry: er.RegistryEntry | None ) -> bool: """Return True if an entity is exposed by default.""" - if ( + if registry_entry and ( registry_entry.entity_category is not None or registry_entry.hidden_by is not None ): @@ -216,7 +323,7 @@ class ExposedEntities: if domain in DEFAULT_EXPOSED_DOMAINS: return True - device_class = get_device_class(self._hass, entity_id) + device_class = get_device_class(self.hass, entity_id) if ( domain == "binary_sensor" and device_class in DEFAULT_EXPOSED_BINARY_SENSOR_DEVICE_CLASSES @@ -228,37 +335,71 @@ class ExposedEntities: return False - async def async_load(self) -> None: + async def _process_create_data(self, data: dict) -> dict: + """Validate the config is valid.""" + return data + + @callback + def _get_suggested_id(self, info: dict) -> str: + """Suggest an ID based on the config.""" + entity_id: str = info["entity_id"] + return entity_id + + async def _update_data( + self, item: ExposedEntity, update_data: dict + ) -> ExposedEntity: + """Return a new updated item.""" + new_assistant_settings: dict[str, Any] = update_data["assistants"] + old_assistant_settings = item.assistants + for assistant, old_settings in old_assistant_settings.items(): + new_settings = new_assistant_settings.get(assistant, {}) + new_assistant_settings[assistant] = old_settings | new_settings + return dataclasses.replace(item, assistants=new_assistant_settings) + + def _create_item(self, item_id: str, data: dict) -> ExposedEntity: + """Create an item from validated config.""" + del data["entity_id"] + return ExposedEntity(**data) + + def _deserialize_item(self, data: dict) -> ExposedEntity: + """Create an item from its serialized representation.""" + del data["entity_id"] + return ExposedEntity(**data) + + def _serialize_item(self, item_id: str, item: ExposedEntity) -> dict: + """Return the serialized representation of an item for storing.""" + return item.to_json(item_id) + + async def _async_load_data(self) -> SerializedExposedEntities | None: """Load from the store.""" - data = await self._store.async_load() + data = await super()._async_load_data() assistants: dict[str, AssistantPreferences] = {} - if data: + if data and "assistants" in data: for domain, preferences in data["assistants"].items(): assistants[domain] = AssistantPreferences(**preferences) self._assistants = assistants - @callback - def _async_schedule_save(self) -> None: - """Schedule saving the preferences.""" - self._store.async_delay_save(self._data_to_save, SAVE_DELAY) - - @callback - def _data_to_save(self) -> dict[str, dict[str, dict[str, Any]]]: - """Return data to store in a file.""" - data = {} - - data["assistants"] = { - domain: preferences.to_json() - for domain, preferences in self._assistants.items() - } + if data and "items" not in data: + return None # type: ignore[unreachable] return data + @callback + def _data_to_save(self) -> SerializedExposedEntities: + """Return JSON-compatible date for storing to file.""" + base_data = super()._base_data_to_save() + return { + "items": base_data["items"], + "assistants": { + domain: preferences.to_json() + for domain, preferences in self._assistants.items() + }, + } + -@callback @websocket_api.require_admin @websocket_api.websocket_command( { @@ -268,11 +409,11 @@ class ExposedEntities: vol.Required("should_expose"): bool, } ) -def ws_expose_entity( +@websocket_api.async_response +async def ws_expose_entity( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: """Expose an entity to an assistant.""" - entity_registry = er.async_get(hass) entity_ids: str = msg["entity_ids"] if blocked := next( @@ -288,28 +429,40 @@ def ws_expose_entity( ) return - if unknown := next( - ( - entity_id - for entity_id in entity_ids - if entity_id not in entity_registry.entities - ), - None, - ): - connection.send_error( - msg["id"], websocket_api.const.ERR_NOT_FOUND, f"can't expose '{unknown}'" - ) - return - exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] for entity_id in entity_ids: for assistant in msg["assistants"]: - exposed_entities.async_expose_entity( + await exposed_entities.async_expose_entity( assistant, entity_id, msg["should_expose"] ) connection.send_result(msg["id"]) +@callback +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "homeassistant/expose_entity/list", + } +) +def ws_list_exposed_entities( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Expose an entity to an assistant.""" + result: dict[str, Any] = {} + + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + entity_registry = er.async_get(hass) + for entity_id in chain(exposed_entities.data, entity_registry.entities): + result[entity_id] = {} + entity_settings = async_get_entity_settings(hass, entity_id) + for assistant, settings in entity_settings.items(): + if "should_expose" not in settings: + continue + result[entity_id][assistant] = settings["should_expose"] + connection.send_result(msg["id"], {"exposed_entities": result}) + + @callback @websocket_api.require_admin @websocket_api.websocket_command( @@ -372,8 +525,7 @@ def async_get_entity_settings( return exposed_entities.async_get_entity_settings(entity_id) -@callback -def async_expose_entity( +async def async_expose_entity( hass: HomeAssistant, assistant: str, entity_id: str, @@ -381,11 +533,12 @@ def async_expose_entity( ) -> None: """Get assistant expose settings for an entity.""" exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] - exposed_entities.async_expose_entity(assistant, entity_id, should_expose) + await exposed_entities.async_expose_entity(assistant, entity_id, should_expose) -@callback -def async_should_expose(hass: HomeAssistant, assistant: str, entity_id: str) -> bool: +async def async_should_expose( + hass: HomeAssistant, assistant: str, entity_id: str +) -> bool: """Return True if an entity should be exposed to an assistant.""" exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] - return exposed_entities.async_should_expose(assistant, entity_id) + return await exposed_entities.async_should_expose(assistant, entity_id) diff --git a/homeassistant/components/switch_as_x/__init__.py b/homeassistant/components/switch_as_x/__init__.py index ef64a86c6e8a..6e6dffd23377 100644 --- a/homeassistant/components/switch_as_x/__init__.py +++ b/homeassistant/components/switch_as_x/__init__.py @@ -138,6 +138,6 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: for assistant, settings in expose_settings.items(): if (should_expose := settings.get("should_expose")) is None: continue - exposed_entities.async_expose_entity( + await exposed_entities.async_expose_entity( hass, assistant, switch_entity_id, should_expose ) diff --git a/homeassistant/components/switch_as_x/entity.py b/homeassistant/components/switch_as_x/entity.py index a73271bdc835..d2e3995b85e2 100644 --- a/homeassistant/components/switch_as_x/entity.py +++ b/homeassistant/components/switch_as_x/entity.py @@ -111,7 +111,7 @@ class BaseEntity(Entity): return registry.async_update_entity(self.entity_id, name=wrapped_switch.name) - def copy_expose_settings() -> None: + async def copy_expose_settings() -> None: """Copy assistant expose settings from the wrapped entity. Also unexpose the wrapped entity if exposed. @@ -122,15 +122,15 @@ class BaseEntity(Entity): for assistant, settings in expose_settings.items(): if (should_expose := settings.get("should_expose")) is None: continue - exposed_entities.async_expose_entity( + await exposed_entities.async_expose_entity( self.hass, assistant, self.entity_id, should_expose ) - exposed_entities.async_expose_entity( + await exposed_entities.async_expose_entity( self.hass, assistant, self._switch_entity_id, False ) copy_custom_name(wrapped_switch) - copy_expose_settings() + await copy_expose_settings() class BaseToggleEntity(BaseEntity, ToggleEntity): diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 601f59fd1187..36cc005bf2f2 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -2450,13 +2450,18 @@ async def test_exclude_filters(hass: HomeAssistant) -> None: hass.states.async_set("cover.deny", "off", {"friendly_name": "Blocked cover"}) alexa_config = MockConfig(hass) - alexa_config.should_expose = entityfilter.generate_filter( + filter = entityfilter.generate_filter( include_domains=[], include_entities=[], exclude_domains=["script"], exclude_entities=["cover.deny"], ) + async def mock_should_expose(entity_id): + return filter(entity_id) + + alexa_config.should_expose = mock_should_expose + msg = await smart_home.async_handle_message(hass, alexa_config, request) await hass.async_block_till_done() @@ -2481,13 +2486,18 @@ async def test_include_filters(hass: HomeAssistant) -> None: hass.states.async_set("group.allow", "off", {"friendly_name": "Allowed group"}) alexa_config = MockConfig(hass) - alexa_config.should_expose = entityfilter.generate_filter( + filter = entityfilter.generate_filter( include_domains=["automation", "group"], include_entities=["script.deny"], exclude_domains=[], exclude_entities=[], ) + async def mock_should_expose(entity_id): + return filter(entity_id) + + alexa_config.should_expose = mock_should_expose + msg = await smart_home.async_handle_message(hass, alexa_config, request) await hass.async_block_till_done() @@ -2506,13 +2516,18 @@ async def test_never_exposed_entities(hass: HomeAssistant) -> None: hass.states.async_set("group.allow", "off", {"friendly_name": "Allowed group"}) alexa_config = MockConfig(hass) - alexa_config.should_expose = entityfilter.generate_filter( + filter = entityfilter.generate_filter( include_domains=["group"], include_entities=[], exclude_domains=[], exclude_entities=[], ) + async def mock_should_expose(entity_id): + return filter(entity_id) + + alexa_config.should_expose = mock_should_expose + msg = await smart_home.async_handle_message(hass, alexa_config, request) await hass.async_block_till_done() diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index f0202e3d958e..8d37eba219ab 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -370,6 +370,7 @@ async def test_websocket_update_orientation_prefs( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, mock_camera ) -> None: """Test updating camera preferences.""" + await async_setup_component(hass, "homeassistant", {}) client = await hass_ws_client(hass) diff --git a/tests/components/cast/test_media_player.py b/tests/components/cast/test_media_player.py index 8001411ac714..46a778f5e314 100644 --- a/tests/components/cast/test_media_player.py +++ b/tests/components/cast/test_media_player.py @@ -1888,6 +1888,7 @@ async def test_failed_cast_other_url( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test warning when casting from internal_url fails.""" + await async_setup_component(hass, "homeassistant", {}) with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component( hass, @@ -1911,6 +1912,7 @@ async def test_failed_cast_internal_url( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test warning when casting from internal_url fails.""" + await async_setup_component(hass, "homeassistant", {}) await async_process_ha_core_config( hass, {"internal_url": "http://example.local:8123"}, @@ -1939,6 +1941,7 @@ async def test_failed_cast_external_url( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test warning when casting from external_url fails.""" + await async_setup_component(hass, "homeassistant", {}) await async_process_ha_core_config( hass, {"external_url": "http://example.com:8123"}, @@ -1969,6 +1972,7 @@ async def test_failed_cast_tts_base_url( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test warning when casting from tts.base_url fails.""" + await async_setup_component(hass, "homeassistant", {}) with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component( hass, diff --git a/tests/components/climate/test_recorder.py b/tests/components/climate/test_recorder.py index 435d1378e84d..b6acf375f2e1 100644 --- a/tests/components/climate/test_recorder.py +++ b/tests/components/climate/test_recorder.py @@ -29,6 +29,7 @@ from tests.components.recorder.common import async_wait_recording_done async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test climate registered attributes to be excluded.""" now = dt_util.utcnow() + await async_setup_component(hass, "homeassistant", {}) await async_setup_component( hass, climate.DOMAIN, {climate.DOMAIN: {"platform": "demo"}} ) diff --git a/tests/components/cloud/test_alexa_config.py b/tests/components/cloud/test_alexa_config.py index 0e1f941ab641..bf4890e92dd8 100644 --- a/tests/components/cloud/test_alexa_config.py +++ b/tests/components/cloud/test_alexa_config.py @@ -18,7 +18,6 @@ from homeassistant.components.homeassistant.exposed_entities import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.setup import async_setup_component @@ -39,10 +38,10 @@ def expose_new(hass, expose_new): exposed_entities.async_set_expose_new_entities("cloud.alexa", expose_new) -def expose_entity(hass, entity_id, should_expose): +async def expose_entity(hass, entity_id, should_expose): """Expose an entity to Alexa.""" exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] - exposed_entities.async_expose_entity("cloud.alexa", entity_id, should_expose) + await exposed_entities.async_expose_entity("cloud.alexa", entity_id, should_expose) async def test_alexa_config_expose_entity_prefs( @@ -96,36 +95,35 @@ async def test_alexa_config_expose_entity_prefs( alexa_report_state=False, ) expose_new(hass, True) - expose_entity(hass, entity_entry5.entity_id, False) + await expose_entity(hass, entity_entry5.entity_id, False) conf = alexa_config.CloudAlexaConfig( hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, cloud_stub ) await conf.async_initialize() - # can't expose an entity which is not in the entity registry - with pytest.raises(HomeAssistantError): - expose_entity(hass, "light.kitchen", True) - assert not conf.should_expose("light.kitchen") + # an entity which is not in the entity registry can be exposed + await expose_entity(hass, "light.kitchen", True) + assert await conf.should_expose("light.kitchen") # categorized and hidden entities should not be exposed - assert not conf.should_expose(entity_entry1.entity_id) - assert not conf.should_expose(entity_entry2.entity_id) - assert not conf.should_expose(entity_entry3.entity_id) - assert not conf.should_expose(entity_entry4.entity_id) + assert not await conf.should_expose(entity_entry1.entity_id) + assert not await conf.should_expose(entity_entry2.entity_id) + assert not await conf.should_expose(entity_entry3.entity_id) + assert not await conf.should_expose(entity_entry4.entity_id) # this has been hidden - assert not conf.should_expose(entity_entry5.entity_id) + assert not await conf.should_expose(entity_entry5.entity_id) # exposed by default - assert conf.should_expose(entity_entry6.entity_id) + assert await conf.should_expose(entity_entry6.entity_id) - expose_entity(hass, entity_entry5.entity_id, True) - assert conf.should_expose(entity_entry5.entity_id) + await expose_entity(hass, entity_entry5.entity_id, True) + assert await conf.should_expose(entity_entry5.entity_id) - expose_entity(hass, entity_entry5.entity_id, None) - assert not conf.should_expose(entity_entry5.entity_id) + await expose_entity(hass, entity_entry5.entity_id, None) + assert not await conf.should_expose(entity_entry5.entity_id) assert "alexa" not in hass.config.components await hass.async_block_till_done() assert "alexa" in hass.config.components - assert not conf.should_expose(entity_entry5.entity_id) + assert not await conf.should_expose(entity_entry5.entity_id) async def test_alexa_config_report_state( @@ -370,7 +368,7 @@ async def test_alexa_update_expose_trigger_sync( await conf.async_initialize() with patch_sync_helper() as (to_update, to_remove): - expose_entity(hass, light_entry.entity_id, True) + await expose_entity(hass, light_entry.entity_id, True) await hass.async_block_till_done() async_fire_time_changed(hass, fire_all=True) await hass.async_block_till_done() @@ -380,9 +378,9 @@ async def test_alexa_update_expose_trigger_sync( assert to_remove == [] with patch_sync_helper() as (to_update, to_remove): - expose_entity(hass, light_entry.entity_id, False) - expose_entity(hass, binary_sensor_entry.entity_id, True) - expose_entity(hass, sensor_entry.entity_id, True) + await expose_entity(hass, light_entry.entity_id, False) + await expose_entity(hass, binary_sensor_entry.entity_id, True) + await expose_entity(hass, sensor_entry.entity_id, True) await hass.async_block_till_done() async_fire_time_changed(hass, fire_all=True) await hass.async_block_till_done() @@ -588,7 +586,7 @@ async def test_alexa_config_migrate_expose_entity_prefs( alexa_report_state=False, alexa_settings_version=1, ) - expose_entity(hass, entity_migrated.entity_id, False) + await expose_entity(hass, entity_migrated.entity_id, False) cloud_prefs._prefs[PREF_ALEXA_ENTITY_CONFIGS]["light.unknown"] = { PREF_SHOULD_EXPOSE: True diff --git a/tests/components/cloud/test_client.py b/tests/components/cloud/test_client.py index d1e1a8ce112a..9bca4b793402 100644 --- a/tests/components/cloud/test_client.py +++ b/tests/components/cloud/test_client.py @@ -265,13 +265,13 @@ async def test_google_config_expose_entity( state = State(entity_entry.entity_id, "on") gconf = await cloud_client.get_google_config() - assert gconf.should_expose(state) + assert await gconf.should_expose(state) - exposed_entities.async_expose_entity( + await exposed_entities.async_expose_entity( "cloud.google_assistant", entity_entry.entity_id, False ) - assert not gconf.should_expose(state) + assert not await gconf.should_expose(state) async def test_google_config_should_2fa( diff --git a/tests/components/cloud/test_google_config.py b/tests/components/cloud/test_google_config.py index 8b927f7f3aa2..51e8de983010 100644 --- a/tests/components/cloud/test_google_config.py +++ b/tests/components/cloud/test_google_config.py @@ -21,7 +21,6 @@ from homeassistant.components.homeassistant.exposed_entities import ( ) from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EntityCategory from homeassistant.core import CoreState, HomeAssistant, State -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow @@ -47,10 +46,10 @@ def expose_new(hass, expose_new): exposed_entities.async_set_expose_new_entities("cloud.google_assistant", expose_new) -def expose_entity(hass, entity_id, should_expose): +async def expose_entity(hass, entity_id, should_expose): """Expose an entity to Google.""" exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] - exposed_entities.async_expose_entity( + await exposed_entities.async_expose_entity( "cloud.google_assistant", entity_id, should_expose ) @@ -151,7 +150,7 @@ async def test_google_update_expose_trigger_sync( with patch.object(config, "async_sync_entities") as mock_sync, patch.object( ga_helpers, "SYNC_DELAY", 0 ): - expose_entity(hass, light_entry.entity_id, True) + await expose_entity(hass, light_entry.entity_id, True) await hass.async_block_till_done() async_fire_time_changed(hass, utcnow()) await hass.async_block_till_done() @@ -161,9 +160,9 @@ async def test_google_update_expose_trigger_sync( with patch.object(config, "async_sync_entities") as mock_sync, patch.object( ga_helpers, "SYNC_DELAY", 0 ): - expose_entity(hass, light_entry.entity_id, False) - expose_entity(hass, binary_sensor_entry.entity_id, True) - expose_entity(hass, sensor_entry.entity_id, True) + await expose_entity(hass, light_entry.entity_id, False) + await expose_entity(hass, binary_sensor_entry.entity_id, True) + await expose_entity(hass, sensor_entry.entity_id, True) await hass.async_block_till_done() async_fire_time_changed(hass, utcnow()) await hass.async_block_till_done() @@ -385,7 +384,7 @@ async def test_google_config_expose_entity_prefs( ) expose_new(hass, True) - expose_entity(hass, entity_entry5.entity_id, False) + await expose_entity(hass, entity_entry5.entity_id, False) state = State("light.kitchen", "on") state_config = State(entity_entry1.entity_id, "on") @@ -395,25 +394,24 @@ async def test_google_config_expose_entity_prefs( state_not_exposed = State(entity_entry5.entity_id, "on") state_exposed_default = State(entity_entry6.entity_id, "on") - # can't expose an entity which is not in the entity registry - with pytest.raises(HomeAssistantError): - expose_entity(hass, "light.kitchen", True) - assert not mock_conf.should_expose(state) + # an entity which is not in the entity registry can be exposed + await expose_entity(hass, "light.kitchen", True) + assert await mock_conf.should_expose(state) # categorized and hidden entities should not be exposed - assert not mock_conf.should_expose(state_config) - assert not mock_conf.should_expose(state_diagnostic) - assert not mock_conf.should_expose(state_hidden_integration) - assert not mock_conf.should_expose(state_hidden_user) + assert not await mock_conf.should_expose(state_config) + assert not await mock_conf.should_expose(state_diagnostic) + assert not await mock_conf.should_expose(state_hidden_integration) + assert not await mock_conf.should_expose(state_hidden_user) # this has been hidden - assert not mock_conf.should_expose(state_not_exposed) + assert not await mock_conf.should_expose(state_not_exposed) # exposed by default - assert mock_conf.should_expose(state_exposed_default) + assert await mock_conf.should_expose(state_exposed_default) - expose_entity(hass, entity_entry5.entity_id, True) - assert mock_conf.should_expose(state_not_exposed) + await expose_entity(hass, entity_entry5.entity_id, True) + assert await mock_conf.should_expose(state_not_exposed) - expose_entity(hass, entity_entry5.entity_id, None) - assert not mock_conf.should_expose(state_not_exposed) + await expose_entity(hass, entity_entry5.entity_id, None) + assert not await mock_conf.should_expose(state_not_exposed) def test_enabled_requires_valid_sub( @@ -537,7 +535,7 @@ async def test_google_config_migrate_expose_entity_prefs( google_report_state=False, google_settings_version=1, ) - expose_entity(hass, entity_migrated.entity_id, False) + await expose_entity(hass, entity_migrated.entity_id, False) cloud_prefs._prefs[PREF_GOOGLE_ENTITY_CONFIGS]["light.unknown"] = { PREF_SHOULD_EXPOSE: True diff --git a/tests/components/color_extractor/test_service.py b/tests/components/color_extractor/test_service.py index a928be477fbe..b1236af89fb5 100644 --- a/tests/components/color_extractor/test_service.py +++ b/tests/components/color_extractor/test_service.py @@ -32,6 +32,12 @@ LIGHT_ENTITY = "light.kitchen_lights" CLOSE_THRESHOLD = 10 +@pytest.fixture(autouse=True) +async def setup_homeassistant(hass: HomeAssistant): + """Set up the homeassistant integration.""" + await async_setup_component(hass, "homeassistant", {}) + + def _close_enough(actual_rgb, testing_rgb): """Validate the given RGB value is in acceptable tolerance.""" # Convert the given RGB values to hue / saturation and then back again diff --git a/tests/components/conversation/__init__.py b/tests/components/conversation/__init__.py index 6eadb0680541..fb455da945b4 100644 --- a/tests/components/conversation/__init__.py +++ b/tests/components/conversation/__init__.py @@ -51,7 +51,9 @@ def expose_new(hass, expose_new): exposed_entities.async_set_expose_new_entities(conversation.DOMAIN, expose_new) -def expose_entity(hass, entity_id, should_expose): +async def expose_entity(hass, entity_id, should_expose): """Expose an entity to the default agent.""" exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] - exposed_entities.async_expose_entity(conversation.DOMAIN, entity_id, should_expose) + await exposed_entities.async_expose_entity( + conversation.DOMAIN, entity_id, should_expose + ) diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index 44bb31119879..daba360f7bf5 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -108,7 +108,7 @@ async def test_exposed_areas( hass.states.async_set(bedroom_light.entity_id, "on") # Hide the bedroom light - expose_entity(hass, bedroom_light.entity_id, False) + await expose_entity(hass, bedroom_light.entity_id, False) result = await conversation.async_converse( hass, "turn on lights in the kitchen", None, Context(), None diff --git a/tests/components/conversation/test_init.py b/tests/components/conversation/test_init.py index 6b981cbbdfa8..b6bb4aad68fd 100644 --- a/tests/components/conversation/test_init.py +++ b/tests/components/conversation/test_init.py @@ -680,7 +680,7 @@ async def test_http_processing_intent_entity_exposed( } # Unexpose the entity - expose_entity(hass, "light.kitchen", False) + await expose_entity(hass, "light.kitchen", False) await hass.async_block_till_done() client = await hass_client() @@ -730,7 +730,7 @@ async def test_http_processing_intent_entity_exposed( } # Now expose the entity - expose_entity(hass, "light.kitchen", True) + await expose_entity(hass, "light.kitchen", True) await hass.async_block_till_done() client = await hass_client() @@ -845,7 +845,7 @@ async def test_http_processing_intent_conversion_not_expose_new( } # Expose the entity - expose_entity(hass, "light.kitchen", True) + await expose_entity(hass, "light.kitchen", True) await hass.async_block_till_done() resp = await client.post( diff --git a/tests/components/demo/conftest.py b/tests/components/demo/conftest.py index 96032c120184..a6182289a866 100644 --- a/tests/components/demo/conftest.py +++ b/tests/components/demo/conftest.py @@ -1,3 +1,14 @@ """demo conftest.""" +import pytest + +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 from tests.components.light.conftest import mock_light_profiles # noqa: F401 + + +@pytest.fixture(autouse=True) +async def setup_homeassistant(hass: HomeAssistant): + """Set up the homeassistant integration.""" + await async_setup_component(hass, "homeassistant", {}) diff --git a/tests/components/emulated_kasa/test_init.py b/tests/components/emulated_kasa/test_init.py index 9b73957ef719..5d294ec3bba6 100644 --- a/tests/components/emulated_kasa/test_init.py +++ b/tests/components/emulated_kasa/test_init.py @@ -2,6 +2,8 @@ import math from unittest.mock import AsyncMock, Mock, patch +import pytest + from homeassistant.components import emulated_kasa from homeassistant.components.emulated_kasa.const import ( CONF_POWER, @@ -132,6 +134,12 @@ CONFIG_SENSOR = { } +@pytest.fixture(autouse=True) +async def setup_homeassistant(hass: HomeAssistant): + """Set up the homeassistant integration.""" + await async_setup_component(hass, "homeassistant", {}) + + def nested_value(ndict, *keys): """Return a nested dict value or None if it doesn't exist.""" if len(keys) == 0: diff --git a/tests/components/fan/test_recorder.py b/tests/components/fan/test_recorder.py index 90cf2f59ee0d..f60a06cb4c50 100644 --- a/tests/components/fan/test_recorder.py +++ b/tests/components/fan/test_recorder.py @@ -19,6 +19,7 @@ from tests.components.recorder.common import async_wait_recording_done async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test fan registered attributes to be excluded.""" now = dt_util.utcnow() + await async_setup_component(hass, "homeassistant", {}) await async_setup_component(hass, fan.DOMAIN, {fan.DOMAIN: {"platform": "demo"}}) await hass.async_block_till_done() async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5)) diff --git a/tests/components/google_assistant/__init__.py b/tests/components/google_assistant/__init__.py index 2122818bbb44..29ad5e6710af 100644 --- a/tests/components/google_assistant/__init__.py +++ b/tests/components/google_assistant/__init__.py @@ -58,7 +58,7 @@ class MockConfig(helpers.AbstractConfig): """Get agent user ID making request.""" return context.user_id - def should_expose(self, state): + async def should_expose(self, state): """Expose it all.""" return self._should_expose is None or self._should_expose(state) diff --git a/tests/components/google_assistant/test_diagnostics.py b/tests/components/google_assistant/test_diagnostics.py index 3f7b536f1639..5f033319c440 100644 --- a/tests/components/google_assistant/test_diagnostics.py +++ b/tests/components/google_assistant/test_diagnostics.py @@ -20,6 +20,7 @@ async def test_diagnostics( await setup.async_setup_component( hass, switch.DOMAIN, {"switch": [{"platform": "demo"}]} ) + await async_setup_component(hass, "homeassistant", {}) await async_setup_component( hass, diff --git a/tests/components/google_assistant/test_helpers.py b/tests/components/google_assistant/test_helpers.py index 793db076c79c..cda3ded25be3 100644 --- a/tests/components/google_assistant/test_helpers.py +++ b/tests/components/google_assistant/test_helpers.py @@ -49,13 +49,13 @@ async def test_google_entity_sync_serialize_with_local_sdk(hass: HomeAssistant) ) entity = helpers.GoogleEntity(hass, config, hass.states.get("light.ceiling_lights")) - serialized = entity.sync_serialize(None, "mock-uuid") + serialized = await entity.sync_serialize(None, "mock-uuid") assert "otherDeviceIds" not in serialized assert "customData" not in serialized config.async_enable_local_sdk() - serialized = entity.sync_serialize("mock-user-id", "abcdef") + serialized = await entity.sync_serialize("mock-user-id", "abcdef") assert serialized["otherDeviceIds"] == [{"deviceId": "light.ceiling_lights"}] assert serialized["customData"] == { "httpPort": 1234, @@ -68,7 +68,7 @@ async def test_google_entity_sync_serialize_with_local_sdk(hass: HomeAssistant) "homeassistant.components.google_assistant.helpers.get_google_type", return_value=device_type, ): - serialized = entity.sync_serialize(None, "mock-uuid") + serialized = await entity.sync_serialize(None, "mock-uuid") assert "otherDeviceIds" not in serialized assert "customData" not in serialized diff --git a/tests/components/google_assistant/test_http.py b/tests/components/google_assistant/test_http.py index b7dc880ede07..5297d6b29e53 100644 --- a/tests/components/google_assistant/test_http.py +++ b/tests/components/google_assistant/test_http.py @@ -257,7 +257,9 @@ async def test_should_expose(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert ( - config.should_expose(State(DOMAIN + ".mock", "mock", {"view": "not None"})) + await config.should_expose( + State(DOMAIN + ".mock", "mock", {"view": "not None"}) + ) is False ) @@ -265,7 +267,10 @@ async def test_should_expose(hass: HomeAssistant) -> None: # Wait for google_assistant.helpers.async_initialize.sync_google to be called await hass.async_block_till_done() - assert config.should_expose(State(CLOUD_NEVER_EXPOSED_ENTITIES[0], "mock")) is False + assert ( + await config.should_expose(State(CLOUD_NEVER_EXPOSED_ENTITIES[0], "mock")) + is False + ) async def test_missing_service_account(hass: HomeAssistant) -> None: diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index 28b7080b7305..ff673ddfe240 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -452,6 +452,7 @@ async def test_execute( ) -> None: """Test an execute command.""" await async_setup_component(hass, "light", {"light": {"platform": "demo"}}) + await async_setup_component(hass, "homeassistant", {}) await hass.async_block_till_done() await hass.services.async_call( @@ -635,6 +636,7 @@ async def test_execute_times_out( orig_execute_limit = sh.EXECUTE_LIMIT sh.EXECUTE_LIMIT = 0.02 # Decrease timeout to 20ms await async_setup_component(hass, "light", {"light": {"platform": "demo"}}) + await async_setup_component(hass, "homeassistant", {}) await hass.async_block_till_done() await hass.services.async_call( @@ -907,7 +909,7 @@ async def test_serialize_input_boolean(hass: HomeAssistant) -> None: """Test serializing an input boolean entity.""" state = State("input_boolean.bla", "on") entity = sh.GoogleEntity(hass, BASIC_CONFIG, state) - result = entity.sync_serialize(None, "mock-uuid") + result = await entity.sync_serialize(None, "mock-uuid") assert result == { "id": "input_boolean.bla", "attributes": {}, diff --git a/tests/components/group/conftest.py b/tests/components/group/conftest.py index e26e98598e6e..3aefbfacdf84 100644 --- a/tests/components/group/conftest.py +++ b/tests/components/group/conftest.py @@ -1,2 +1,13 @@ """group conftest.""" +import pytest + +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + from tests.components.light.conftest import mock_light_profiles # noqa: F401 + + +@pytest.fixture(autouse=True) +async def setup_homeassistant(hass: HomeAssistant): + """Set up the homeassistant integration.""" + await async_setup_component(hass, "homeassistant", {}) diff --git a/tests/components/group/test_recorder.py b/tests/components/group/test_recorder.py index 5dbe1ee5618b..3ca965ec998e 100644 --- a/tests/components/group/test_recorder.py +++ b/tests/components/group/test_recorder.py @@ -3,6 +3,8 @@ from __future__ import annotations from datetime import timedelta +import pytest + from homeassistant.components import group from homeassistant.components.group import ATTR_AUTO, ATTR_ENTITY_ID, ATTR_ORDER from homeassistant.components.recorder import Recorder @@ -16,6 +18,11 @@ from tests.common import async_fire_time_changed from tests.components.recorder.common import async_wait_recording_done +@pytest.fixture(autouse=True) +async def setup_homeassistant(): + """Override the fixture in group.conftest.""" + + async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test number registered attributes to be excluded.""" now = dt_util.utcnow() diff --git a/tests/components/homeassistant/test_exposed_entities.py b/tests/components/homeassistant/test_exposed_entities.py index 1aa98ab423f4..7b3e627011a2 100644 --- a/tests/components/homeassistant/test_exposed_entities.py +++ b/tests/components/homeassistant/test_exposed_entities.py @@ -4,13 +4,13 @@ import pytest from homeassistant.components.homeassistant.exposed_entities import ( DATA_EXPOSED_ENTITIES, ExposedEntities, + ExposedEntity, async_get_assistant_settings, async_listen_entity_updates, async_should_expose, ) from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component @@ -31,7 +31,7 @@ async def test_load_preferences(hass: HomeAssistant) -> None: assert list(exposed_entities._assistants) == ["test1", "test2"] exposed_entities2 = ExposedEntities(hass) - await flush_store(exposed_entities._store) + await flush_store(exposed_entities.store) await exposed_entities2.async_load() assert exposed_entities._assistants == exposed_entities2._assistants @@ -50,6 +50,9 @@ async def test_expose_entity( entry1 = entity_registry.async_get_or_create("test", "test", "unique1") entry2 = entity_registry.async_get_or_create("test", "test", "unique2") + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + assert len(exposed_entities.data) == 0 + # Set options await ws_client.send_json_auto_id( { @@ -67,6 +70,7 @@ async def test_expose_entity( assert entry1.options == {"cloud.alexa": {"should_expose": True}} entry2 = entity_registry.async_get(entry2.entity_id) assert entry2.options == {} + assert len(exposed_entities.data) == 0 # Update options await ws_client.send_json_auto_id( @@ -91,6 +95,7 @@ async def test_expose_entity( "cloud.alexa": {"should_expose": False}, "cloud.google_assistant": {"should_expose": False}, } + assert len(exposed_entities.data) == 0 async def test_expose_entity_unknown( @@ -103,6 +108,7 @@ async def test_expose_entity_unknown( await hass.async_block_till_done() exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + assert len(exposed_entities.data) == 0 # Set options await ws_client.send_json_auto_id( @@ -115,14 +121,41 @@ async def test_expose_entity_unknown( ) response = await ws_client.receive_json() - assert not response["success"] - assert response["error"] == { - "code": "not_found", - "message": "can't expose 'test.test'", + assert response["success"] + + assert len(exposed_entities.data) == 1 + assert exposed_entities.data == { + "test.test": ExposedEntity({"cloud.alexa": {"should_expose": True}}) } - with pytest.raises(HomeAssistantError): - exposed_entities.async_expose_entity("cloud.alexa", "test.test", True) + # Update options + await ws_client.send_json_auto_id( + { + "type": "homeassistant/expose_entity", + "assistants": ["cloud.alexa", "cloud.google_assistant"], + "entity_ids": ["test.test", "test.test2"], + "should_expose": False, + } + ) + + response = await ws_client.receive_json() + assert response["success"] + + assert len(exposed_entities.data) == 2 + assert exposed_entities.data == { + "test.test": ExposedEntity( + { + "cloud.alexa": {"should_expose": False}, + "cloud.google_assistant": {"should_expose": False}, + } + ), + "test.test2": ExposedEntity( + { + "cloud.alexa": {"should_expose": False}, + "cloud.google_assistant": {"should_expose": False}, + } + ), + } async def test_expose_entity_blocked( @@ -178,7 +211,7 @@ async def test_expose_new_entities( assert response["result"] == {"expose_new": False} # Check if exposed - should be False - assert async_should_expose(hass, "cloud.alexa", entry1.entity_id) is False + assert await async_should_expose(hass, "cloud.alexa", entry1.entity_id) is False # Expose new entities to Alexa await ws_client.send_json_auto_id( @@ -201,10 +234,12 @@ async def test_expose_new_entities( assert response["result"] == {"expose_new": expose_new} # Check again if exposed - should still be False - assert async_should_expose(hass, "cloud.alexa", entry1.entity_id) is False + assert await async_should_expose(hass, "cloud.alexa", entry1.entity_id) is False # Check if exposed - should be True - assert async_should_expose(hass, "cloud.alexa", entry2.entity_id) == expose_new + assert ( + await async_should_expose(hass, "cloud.alexa", entry2.entity_id) == expose_new + ) async def test_listen_updates( @@ -226,21 +261,21 @@ async def test_listen_updates( entry = entity_registry.async_get_or_create("climate", "test", "unique1") # Call for another assistant - listener not called - exposed_entities.async_expose_entity( + await exposed_entities.async_expose_entity( "cloud.google_assistant", entry.entity_id, True ) assert len(calls) == 0 # Call for our assistant - listener called - exposed_entities.async_expose_entity("cloud.alexa", entry.entity_id, True) + await exposed_entities.async_expose_entity("cloud.alexa", entry.entity_id, True) assert len(calls) == 1 # Settings not changed - listener not called - exposed_entities.async_expose_entity("cloud.alexa", entry.entity_id, True) + await exposed_entities.async_expose_entity("cloud.alexa", entry.entity_id, True) assert len(calls) == 1 # Settings changed - listener called - exposed_entities.async_expose_entity("cloud.alexa", entry.entity_id, False) + await exposed_entities.async_expose_entity("cloud.alexa", entry.entity_id, False) assert len(calls) == 2 @@ -258,7 +293,7 @@ async def test_get_assistant_settings( assert async_get_assistant_settings(hass, "cloud.alexa") == {} - exposed_entities.async_expose_entity("cloud.alexa", entry.entity_id, True) + await exposed_entities.async_expose_entity("cloud.alexa", entry.entity_id, True) assert async_get_assistant_settings(hass, "cloud.alexa") == { "climate.test_unique1": {"should_expose": True} } @@ -287,40 +322,44 @@ async def test_should_expose( assert response["success"] # Unknown entity is not exposed - assert async_should_expose(hass, "test.test", "test.test") is False + assert await async_should_expose(hass, "test.test", "test.test") is False # Blocked entity is not exposed entry_blocked = entity_registry.async_get_or_create( "group", "test", "unique", suggested_object_id="all_locks" ) assert entry_blocked.entity_id == CLOUD_NEVER_EXPOSED_ENTITIES[0] - assert async_should_expose(hass, "cloud.alexa", entry_blocked.entity_id) is False + assert ( + await async_should_expose(hass, "cloud.alexa", entry_blocked.entity_id) is False + ) # Lock is exposed lock1 = entity_registry.async_get_or_create("lock", "test", "unique1") assert entry_blocked.entity_id == CLOUD_NEVER_EXPOSED_ENTITIES[0] - assert async_should_expose(hass, "cloud.alexa", lock1.entity_id) is True + assert await async_should_expose(hass, "cloud.alexa", lock1.entity_id) is True # Hidden entity is not exposed lock2 = entity_registry.async_get_or_create( "lock", "test", "unique2", hidden_by=er.RegistryEntryHider.USER ) assert entry_blocked.entity_id == CLOUD_NEVER_EXPOSED_ENTITIES[0] - assert async_should_expose(hass, "cloud.alexa", lock2.entity_id) is False + assert await async_should_expose(hass, "cloud.alexa", lock2.entity_id) is False # Entity with category is not exposed lock3 = entity_registry.async_get_or_create( "lock", "test", "unique3", entity_category=EntityCategory.CONFIG ) assert entry_blocked.entity_id == CLOUD_NEVER_EXPOSED_ENTITIES[0] - assert async_should_expose(hass, "cloud.alexa", lock3.entity_id) is False + assert await async_should_expose(hass, "cloud.alexa", lock3.entity_id) is False # Binary sensor without device class is not exposed binarysensor1 = entity_registry.async_get_or_create( "binary_sensor", "test", "unique1" ) assert entry_blocked.entity_id == CLOUD_NEVER_EXPOSED_ENTITIES[0] - assert async_should_expose(hass, "cloud.alexa", binarysensor1.entity_id) is False + assert ( + await async_should_expose(hass, "cloud.alexa", binarysensor1.entity_id) is False + ) # Binary sensor with certain device class is exposed binarysensor2 = entity_registry.async_get_or_create( @@ -330,12 +369,14 @@ async def test_should_expose( original_device_class="door", ) assert entry_blocked.entity_id == CLOUD_NEVER_EXPOSED_ENTITIES[0] - assert async_should_expose(hass, "cloud.alexa", binarysensor2.entity_id) is True + assert ( + await async_should_expose(hass, "cloud.alexa", binarysensor2.entity_id) is True + ) # Sensor without device class is not exposed sensor1 = entity_registry.async_get_or_create("sensor", "test", "unique1") assert entry_blocked.entity_id == CLOUD_NEVER_EXPOSED_ENTITIES[0] - assert async_should_expose(hass, "cloud.alexa", sensor1.entity_id) is False + assert await async_should_expose(hass, "cloud.alexa", sensor1.entity_id) is False # Sensor with certain device class is exposed sensor2 = entity_registry.async_get_or_create( @@ -345,4 +386,58 @@ async def test_should_expose( original_device_class="temperature", ) assert entry_blocked.entity_id == CLOUD_NEVER_EXPOSED_ENTITIES[0] - assert async_should_expose(hass, "cloud.alexa", sensor2.entity_id) is True + assert await async_should_expose(hass, "cloud.alexa", sensor2.entity_id) is True + + +async def test_list_exposed_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test list exposed entities.""" + ws_client = await hass_ws_client(hass) + assert await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() + + entry1 = entity_registry.async_get_or_create("test", "test", "unique1") + entry2 = entity_registry.async_get_or_create("test", "test", "unique2") + + # Set options for registered entities + await ws_client.send_json_auto_id( + { + "type": "homeassistant/expose_entity", + "assistants": ["cloud.alexa", "cloud.google_assistant"], + "entity_ids": [entry1.entity_id, entry2.entity_id], + "should_expose": True, + } + ) + response = await ws_client.receive_json() + assert response["success"] + + # Set options for entities not in the entity registry + await ws_client.send_json_auto_id( + { + "type": "homeassistant/expose_entity", + "assistants": ["cloud.alexa", "cloud.google_assistant"], + "entity_ids": [ + "test.test", + "test.test2", + ], + "should_expose": False, + } + ) + response = await ws_client.receive_json() + assert response["success"] + + # List exposed entities + await ws_client.send_json_auto_id({"type": "homeassistant/expose_entity/list"}) + response = await ws_client.receive_json() + assert response["success"] + assert response["result"] == { + "exposed_entities": { + "test.test": {"cloud.alexa": False, "cloud.google_assistant": False}, + "test.test2": {"cloud.alexa": False, "cloud.google_assistant": False}, + "test.test_unique1": {"cloud.alexa": True, "cloud.google_assistant": True}, + "test.test_unique2": {"cloud.alexa": True, "cloud.google_assistant": True}, + }, + } diff --git a/tests/components/homekit/test_config_flow.py b/tests/components/homekit/test_config_flow.py index 3de10491f391..b925fcb341cc 100644 --- a/tests/components/homekit/test_config_flow.py +++ b/tests/components/homekit/test_config_flow.py @@ -427,6 +427,7 @@ async def test_options_flow_devices( demo_config_entry = MockConfigEntry(domain="domain") demo_config_entry.add_to_hass(hass) + assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component(hass, "demo", {"demo": {}}) assert await async_setup_component(hass, "homekit", {"homekit": {}}) diff --git a/tests/components/homekit/test_diagnostics.py b/tests/components/homekit/test_diagnostics.py index 58babc0ccb09..69e2aa2c8e27 100644 --- a/tests/components/homekit/test_diagnostics.py +++ b/tests/components/homekit/test_diagnostics.py @@ -319,6 +319,7 @@ async def test_config_entry_with_trigger_accessory( entity_registry: er.EntityRegistry, ) -> None: """Test generating diagnostics for a bridge config entry with a trigger accessory.""" + assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component(hass, "demo", {"demo": {}}) hk_driver.publish = MagicMock() diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index 773dd1715113..0b74763c6a7a 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -747,6 +747,7 @@ async def test_homekit_start_with_a_device( entry = MockConfigEntry( domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} ) + assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component(hass, "demo", {"demo": {}}) await hass.async_block_till_done() diff --git a/tests/components/homekit/test_init.py b/tests/components/homekit/test_init.py index 2bb9a4972a3e..5a1d42352fe5 100644 --- a/tests/components/homekit/test_init.py +++ b/tests/components/homekit/test_init.py @@ -81,6 +81,7 @@ async def test_bridge_with_triggers( an above or below additional configuration which we have no way to input, we ignore them. """ + assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component(hass, "demo", {"demo": {}}) await hass.async_block_till_done() diff --git a/tests/components/homekit/test_type_triggers.py b/tests/components/homekit/test_type_triggers.py index fd77499ff091..0374f3f1e94a 100644 --- a/tests/components/homekit/test_type_triggers.py +++ b/tests/components/homekit/test_type_triggers.py @@ -25,6 +25,7 @@ async def test_programmable_switch_button_fires_on_trigger( demo_config_entry = MockConfigEntry(domain="domain") demo_config_entry.add_to_hass(hass) + assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component(hass, "demo", {"demo": {}}) await hass.async_block_till_done() hass.states.async_set("light.ceiling_lights", STATE_OFF) diff --git a/tests/components/light/test_recorder.py b/tests/components/light/test_recorder.py index c139ff4cf4a4..2c85dac0bd45 100644 --- a/tests/components/light/test_recorder.py +++ b/tests/components/light/test_recorder.py @@ -26,6 +26,7 @@ from tests.components.recorder.common import async_wait_recording_done async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test light registered attributes to be excluded.""" now = dt_util.utcnow() + assert await async_setup_component(hass, "homeassistant", {}) await async_setup_component( hass, light.DOMAIN, {light.DOMAIN: {"platform": "demo"}} ) diff --git a/tests/components/number/test_recorder.py b/tests/components/number/test_recorder.py index 5b382f2540d2..d996a67f93ba 100644 --- a/tests/components/number/test_recorder.py +++ b/tests/components/number/test_recorder.py @@ -18,6 +18,7 @@ from tests.components.recorder.common import async_wait_recording_done async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test number registered attributes to be excluded.""" + assert await async_setup_component(hass, "homeassistant", {}) await async_setup_component( hass, number.DOMAIN, {number.DOMAIN: {"platform": "demo"}} ) diff --git a/tests/components/select/test_recorder.py b/tests/components/select/test_recorder.py index 4fb6f60e0f68..903d24d39bb0 100644 --- a/tests/components/select/test_recorder.py +++ b/tests/components/select/test_recorder.py @@ -19,6 +19,7 @@ from tests.components.recorder.common import async_wait_recording_done async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test select registered attributes to be excluded.""" now = dt_util.utcnow() + assert await async_setup_component(hass, "homeassistant", {}) await async_setup_component( hass, select.DOMAIN, {select.DOMAIN: {"platform": "demo"}} ) diff --git a/tests/components/switch/test_light.py b/tests/components/switch/test_light.py index a77ee314088e..2254abc08f9b 100644 --- a/tests/components/switch/test_light.py +++ b/tests/components/switch/test_light.py @@ -1,4 +1,6 @@ """The tests for the Light Switch platform.""" +import pytest + from homeassistant.components.light import ( ATTR_COLOR_MODE, ATTR_SUPPORTED_COLOR_MODES, @@ -12,6 +14,12 @@ from . import common as switch_common from tests.components.light import common +@pytest.fixture(autouse=True) +async def setup_homeassistant(hass: HomeAssistant): + """Set up the homeassistant integration.""" + await async_setup_component(hass, "homeassistant", {}) + + async def test_default_state(hass: HomeAssistant) -> None: """Test light switch default state.""" await async_setup_component( diff --git a/tests/components/switch_as_x/conftest.py b/tests/components/switch_as_x/conftest.py index f722292fc894..d324f7a0c546 100644 --- a/tests/components/switch_as_x/conftest.py +++ b/tests/components/switch_as_x/conftest.py @@ -6,6 +6,15 @@ from unittest.mock import AsyncMock, patch import pytest +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + + +@pytest.fixture(autouse=True) +async def setup_homeassistant(hass: HomeAssistant): + """Set up the homeassistant integration.""" + await async_setup_component(hass, "homeassistant", {}) + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock, None, None]: diff --git a/tests/components/switch_as_x/test_init.py b/tests/components/switch_as_x/test_init.py index fac744d0c0ed..ad7bf732dcce 100644 --- a/tests/components/switch_as_x/test_init.py +++ b/tests/components/switch_as_x/test_init.py @@ -702,7 +702,7 @@ async def test_import_expose_settings_1( original_name="ABC", ) for assistant, should_expose in EXPOSE_SETTINGS.items(): - exposed_entities.async_expose_entity( + await exposed_entities.async_expose_entity( hass, assistant, switch_entity_entry.entity_id, should_expose ) @@ -760,7 +760,7 @@ async def test_import_expose_settings_2( original_name="ABC", ) for assistant, should_expose in EXPOSE_SETTINGS.items(): - exposed_entities.async_expose_entity( + await exposed_entities.async_expose_entity( hass, assistant, switch_entity_entry.entity_id, should_expose ) @@ -785,7 +785,7 @@ async def test_import_expose_settings_2( suggested_object_id="abc", ) for assistant, should_expose in EXPOSE_SETTINGS.items(): - exposed_entities.async_expose_entity( + await exposed_entities.async_expose_entity( hass, assistant, switch_as_x_entity_entry.entity_id, not should_expose ) @@ -850,7 +850,7 @@ async def test_restore_expose_settings( suggested_object_id="abc", ) for assistant, should_expose in EXPOSE_SETTINGS.items(): - exposed_entities.async_expose_entity( + await exposed_entities.async_expose_entity( hass, assistant, switch_as_x_entity_entry.entity_id, should_expose ) diff --git a/tests/components/text/test_recorder.py b/tests/components/text/test_recorder.py index f695ce11117b..54134ee501a6 100644 --- a/tests/components/text/test_recorder.py +++ b/tests/components/text/test_recorder.py @@ -19,6 +19,7 @@ from tests.components.recorder.common import async_wait_recording_done async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test siren registered attributes to be excluded.""" now = dt_util.utcnow() + assert await async_setup_component(hass, "homeassistant", {}) await async_setup_component(hass, text.DOMAIN, {text.DOMAIN: {"platform": "demo"}}) await hass.async_block_till_done() async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5)) From 17db1aa38bf28122e495d4839f003ca373567108 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 2 May 2023 15:39:41 -0500 Subject: [PATCH 136/822] Bump voip-utils to 0.0.7 (#92372) --- homeassistant/components/voip/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/voip/manifest.json b/homeassistant/components/voip/manifest.json index 2842e494e7ed..345480da363f 100644 --- a/homeassistant/components/voip/manifest.json +++ b/homeassistant/components/voip/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/voip", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["voip-utils==0.0.6"] + "requirements": ["voip-utils==0.0.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index b0038703c088..71b06d8f308a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2585,7 +2585,7 @@ venstarcolortouch==0.19 vilfo-api-client==0.3.2 # homeassistant.components.voip -voip-utils==0.0.6 +voip-utils==0.0.7 # homeassistant.components.volkszaehler volkszaehler==0.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 925255ff3fe0..169b7930bd8a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1870,7 +1870,7 @@ venstarcolortouch==0.19 vilfo-api-client==0.3.2 # homeassistant.components.voip -voip-utils==0.0.6 +voip-utils==0.0.7 # homeassistant.components.volvooncall volvooncall==0.10.2 From 5ed41d3d51f648779f855a426e7e4820af24158d Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 2 May 2023 22:41:35 +0200 Subject: [PATCH 137/822] Update frontend to 20230502.0 (#92373) --- 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 a6eb045f61c6..8167b9bbb17a 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==20230501.0"] + "requirements": ["home-assistant-frontend==20230502.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index cfac6ae32778..19be55ceb266 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -25,7 +25,7 @@ ha-av==10.0.0 hass-nabucasa==0.66.2 hassil==1.0.6 home-assistant-bluetooth==1.10.0 -home-assistant-frontend==20230501.0 +home-assistant-frontend==20230502.0 home-assistant-intents==2023.4.26 httpx==0.24.0 ifaddr==0.1.7 diff --git a/requirements_all.txt b/requirements_all.txt index 71b06d8f308a..b6483853babf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -911,7 +911,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230501.0 +home-assistant-frontend==20230502.0 # homeassistant.components.conversation home-assistant-intents==2023.4.26 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 169b7930bd8a..c9d10e349930 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -700,7 +700,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230501.0 +home-assistant-frontend==20230502.0 # homeassistant.components.conversation home-assistant-intents==2023.4.26 From 1baa4d51093fb5d7825822c9c79696589de158a8 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 2 May 2023 22:38:54 -0400 Subject: [PATCH 138/822] Fix deserialize bug + add test coverage (#92382) --- .../homeassistant/exposed_entities.py | 10 +- .../snapshots/test_exposed_entities.ambr | 25 +++ .../homeassistant/test_exposed_entities.py | 161 ++++++++++++------ 3 files changed, 143 insertions(+), 53 deletions(-) create mode 100644 tests/components/homeassistant/snapshots/test_exposed_entities.ambr diff --git a/homeassistant/components/homeassistant/exposed_entities.py b/homeassistant/components/homeassistant/exposed_entities.py index 9217e073fe46..81b3a60b3f5f 100644 --- a/homeassistant/components/homeassistant/exposed_entities.py +++ b/homeassistant/components/homeassistant/exposed_entities.py @@ -358,13 +358,15 @@ class ExposedEntities(StorageCollection[ExposedEntity, SerializedExposedEntities def _create_item(self, item_id: str, data: dict) -> ExposedEntity: """Create an item from validated config.""" - del data["entity_id"] - return ExposedEntity(**data) + return ExposedEntity( + assistants=data["assistants"], + ) def _deserialize_item(self, data: dict) -> ExposedEntity: """Create an item from its serialized representation.""" - del data["entity_id"] - return ExposedEntity(**data) + return ExposedEntity( + assistants=data["assistants"], + ) def _serialize_item(self, item_id: str, item: ExposedEntity) -> dict: """Return the serialized representation of an item for storing.""" diff --git a/tests/components/homeassistant/snapshots/test_exposed_entities.ambr b/tests/components/homeassistant/snapshots/test_exposed_entities.ambr new file mode 100644 index 000000000000..2f9d0b8017f9 --- /dev/null +++ b/tests/components/homeassistant/snapshots/test_exposed_entities.ambr @@ -0,0 +1,25 @@ +# serializer version: 1 +# name: test_get_assistant_settings + dict({ + 'climate.test_unique1': mappingproxy({ + 'should_expose': True, + }), + 'light.not_in_registry': dict({ + 'should_expose': True, + }), + }) +# --- +# name: test_get_assistant_settings.1 + dict({ + }) +# --- +# name: test_listeners + dict({ + 'light.kitchen': dict({ + 'should_expose': True, + }), + 'switch.test_unique1': mappingproxy({ + 'should_expose': True, + }), + }) +# --- diff --git a/tests/components/homeassistant/test_exposed_entities.py b/tests/components/homeassistant/test_exposed_entities.py index 7b3e627011a2..08e0050ef815 100644 --- a/tests/components/homeassistant/test_exposed_entities.py +++ b/tests/components/homeassistant/test_exposed_entities.py @@ -1,16 +1,19 @@ """Test Home Assistant exposed entities helper.""" import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.homeassistant.exposed_entities import ( DATA_EXPOSED_ENTITIES, ExposedEntities, ExposedEntity, + async_expose_entity, async_get_assistant_settings, async_listen_entity_updates, async_should_expose, ) from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES, EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component @@ -30,11 +33,18 @@ async def test_load_preferences(hass: HomeAssistant) -> None: assert list(exposed_entities._assistants) == ["test1", "test2"] - exposed_entities2 = ExposedEntities(hass) + await exposed_entities.async_expose_entity("test1", "light.kitchen", True) + await exposed_entities.async_expose_entity("test1", "light.living_room", True) + await exposed_entities.async_expose_entity("test2", "light.kitchen", True) + await exposed_entities.async_expose_entity("test2", "light.kitchen", True) + await flush_store(exposed_entities.store) + + exposed_entities2 = ExposedEntities(hass) await exposed_entities2.async_load() assert exposed_entities._assistants == exposed_entities2._assistants + assert exposed_entities.data == exposed_entities2.data async def test_expose_entity( @@ -282,6 +292,7 @@ async def test_listen_updates( async def test_get_assistant_settings( hass: HomeAssistant, entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test get assistant settings.""" assert await async_setup_component(hass, "homeassistant", {}) @@ -294,16 +305,22 @@ async def test_get_assistant_settings( assert async_get_assistant_settings(hass, "cloud.alexa") == {} await exposed_entities.async_expose_entity("cloud.alexa", entry.entity_id, True) - assert async_get_assistant_settings(hass, "cloud.alexa") == { - "climate.test_unique1": {"should_expose": True} - } - assert async_get_assistant_settings(hass, "cloud.google_assistant") == {} + await exposed_entities.async_expose_entity( + "cloud.alexa", "light.not_in_registry", True + ) + assert async_get_assistant_settings(hass, "cloud.alexa") == snapshot + assert async_get_assistant_settings(hass, "cloud.google_assistant") == snapshot + + with pytest.raises(HomeAssistantError): + exposed_entities.async_get_entity_settings("light.unknown") +@pytest.mark.parametrize("use_registry", [True, False]) async def test_should_expose( hass: HomeAssistant, entity_registry: er.EntityRegistry, hass_ws_client: WebSocketGenerator, + use_registry: bool, ) -> None: """Test expose entity.""" ws_client = await hass_ws_client(hass) @@ -325,68 +342,96 @@ async def test_should_expose( assert await async_should_expose(hass, "test.test", "test.test") is False # Blocked entity is not exposed - entry_blocked = entity_registry.async_get_or_create( - "group", "test", "unique", suggested_object_id="all_locks" - ) - assert entry_blocked.entity_id == CLOUD_NEVER_EXPOSED_ENTITIES[0] - assert ( - await async_should_expose(hass, "cloud.alexa", entry_blocked.entity_id) is False - ) + if use_registry: + entry_blocked = entity_registry.async_get_or_create( + "group", "test", "unique", suggested_object_id="all_locks" + ) + assert entry_blocked.entity_id == "group.all_locks" + assert CLOUD_NEVER_EXPOSED_ENTITIES[0] == "group.all_locks" + assert await async_should_expose(hass, "cloud.alexa", "group.all_locks") is False # Lock is exposed - lock1 = entity_registry.async_get_or_create("lock", "test", "unique1") - assert entry_blocked.entity_id == CLOUD_NEVER_EXPOSED_ENTITIES[0] - assert await async_should_expose(hass, "cloud.alexa", lock1.entity_id) is True + if use_registry: + entity_registry.async_get_or_create("lock", "test", "unique1") + assert await async_should_expose(hass, "cloud.alexa", "lock.test_unique1") is True # Hidden entity is not exposed - lock2 = entity_registry.async_get_or_create( - "lock", "test", "unique2", hidden_by=er.RegistryEntryHider.USER - ) - assert entry_blocked.entity_id == CLOUD_NEVER_EXPOSED_ENTITIES[0] - assert await async_should_expose(hass, "cloud.alexa", lock2.entity_id) is False + if use_registry: + entity_registry.async_get_or_create( + "lock", "test", "unique2", hidden_by=er.RegistryEntryHider.USER + ) + assert ( + await async_should_expose(hass, "cloud.alexa", "lock.test_unique2") is False + ) - # Entity with category is not exposed - lock3 = entity_registry.async_get_or_create( - "lock", "test", "unique3", entity_category=EntityCategory.CONFIG - ) - assert entry_blocked.entity_id == CLOUD_NEVER_EXPOSED_ENTITIES[0] - assert await async_should_expose(hass, "cloud.alexa", lock3.entity_id) is False + # Entity with category is not exposed + entity_registry.async_get_or_create( + "lock", "test", "unique3", entity_category=EntityCategory.CONFIG + ) + assert ( + await async_should_expose(hass, "cloud.alexa", "lock.test_unique3") is False + ) # Binary sensor without device class is not exposed - binarysensor1 = entity_registry.async_get_or_create( - "binary_sensor", "test", "unique1" - ) - assert entry_blocked.entity_id == CLOUD_NEVER_EXPOSED_ENTITIES[0] + if use_registry: + entity_registry.async_get_or_create("binary_sensor", "test", "unique1") + else: + hass.states.async_set("binary_sensor.test_unique1", "on", {}) assert ( - await async_should_expose(hass, "cloud.alexa", binarysensor1.entity_id) is False + await async_should_expose(hass, "cloud.alexa", "binary_sensor.test_unique1") + is False ) # Binary sensor with certain device class is exposed - binarysensor2 = entity_registry.async_get_or_create( - "binary_sensor", - "test", - "unique2", - original_device_class="door", - ) - assert entry_blocked.entity_id == CLOUD_NEVER_EXPOSED_ENTITIES[0] + if use_registry: + entity_registry.async_get_or_create( + "binary_sensor", + "test", + "unique2", + original_device_class="door", + ) + else: + hass.states.async_set( + "binary_sensor.test_unique2", "on", {"device_class": "door"} + ) assert ( - await async_should_expose(hass, "cloud.alexa", binarysensor2.entity_id) is True + await async_should_expose(hass, "cloud.alexa", "binary_sensor.test_unique2") + is True ) # Sensor without device class is not exposed - sensor1 = entity_registry.async_get_or_create("sensor", "test", "unique1") - assert entry_blocked.entity_id == CLOUD_NEVER_EXPOSED_ENTITIES[0] - assert await async_should_expose(hass, "cloud.alexa", sensor1.entity_id) is False + if use_registry: + entity_registry.async_get_or_create("sensor", "test", "unique1") + else: + hass.states.async_set("sensor.test_unique1", "on", {}) + assert ( + await async_should_expose(hass, "cloud.alexa", "sensor.test_unique1") is False + ) # Sensor with certain device class is exposed - sensor2 = entity_registry.async_get_or_create( - "sensor", - "test", - "unique2", - original_device_class="temperature", + if use_registry: + entity_registry.async_get_or_create( + "sensor", + "test", + "unique2", + original_device_class="temperature", + ) + else: + hass.states.async_set( + "sensor.test_unique2", "on", {"device_class": "temperature"} + ) + assert await async_should_expose(hass, "cloud.alexa", "sensor.test_unique2") is True + # The second time we check, it should load it from storage + assert await async_should_expose(hass, "cloud.alexa", "sensor.test_unique2") is True + # Check with a different assistant + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + exposed_entities.async_set_expose_new_entities("cloud.no_default_expose", False) + assert ( + await async_should_expose( + hass, "cloud.no_default_expose", "sensor.test_unique2" + ) + is False ) - assert entry_blocked.entity_id == CLOUD_NEVER_EXPOSED_ENTITIES[0] - assert await async_should_expose(hass, "cloud.alexa", sensor2.entity_id) is True async def test_list_exposed_entities( @@ -441,3 +486,21 @@ async def test_list_exposed_entities( "test.test_unique2": {"cloud.alexa": True, "cloud.google_assistant": True}, }, } + + +async def test_listeners( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Make sure we call entity listeners.""" + assert await async_setup_component(hass, "homeassistant", {}) + + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + + callbacks = [] + exposed_entities.async_listen_entity_updates("test1", lambda: callbacks.append(1)) + + await async_expose_entity(hass, "test1", "light.kitchen", True) + assert len(callbacks) == 1 + + entry1 = entity_registry.async_get_or_create("switch", "test", "unique1") + await async_expose_entity(hass, "test1", entry1.entity_id, True) From d417cbaf0f5927b5377048de4bbd6bbba80032fe Mon Sep 17 00:00:00 2001 From: Artem Draft Date: Wed, 3 May 2023 09:52:56 +0300 Subject: [PATCH 139/822] Bump pybravia to 0.3.3 (#92378) --- homeassistant/components/braviatv/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/braviatv/manifest.json b/homeassistant/components/braviatv/manifest.json index c5b42e73beef..5a0a9def0ae7 100644 --- a/homeassistant/components/braviatv/manifest.json +++ b/homeassistant/components/braviatv/manifest.json @@ -7,7 +7,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["pybravia"], - "requirements": ["pybravia==0.3.2"], + "requirements": ["pybravia==0.3.3"], "ssdp": [ { "st": "urn:schemas-sony-com:service:ScalarWebAPI:1", diff --git a/requirements_all.txt b/requirements_all.txt index b6483853babf..11635fe66417 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1527,7 +1527,7 @@ pyblackbird==0.6 pybotvac==0.0.23 # homeassistant.components.braviatv -pybravia==0.3.2 +pybravia==0.3.3 # homeassistant.components.nissan_leaf pycarwings2==2.14 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c9d10e349930..30aaf1e2b056 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1130,7 +1130,7 @@ pyblackbird==0.6 pybotvac==0.0.23 # homeassistant.components.braviatv -pybravia==0.3.2 +pybravia==0.3.3 # homeassistant.components.cloudflare pycfdns==2.0.1 From 7aa94f97c06f0035c7943fb794b269437a6e389c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 3 May 2023 10:49:01 +0200 Subject: [PATCH 140/822] Improve exposed entities tests (#92389) --- .../homeassistant/test_exposed_entities.py | 196 +++++++++++------- 1 file changed, 126 insertions(+), 70 deletions(-) diff --git a/tests/components/homeassistant/test_exposed_entities.py b/tests/components/homeassistant/test_exposed_entities.py index 08e0050ef815..4f9a78625dbc 100644 --- a/tests/components/homeassistant/test_exposed_entities.py +++ b/tests/components/homeassistant/test_exposed_entities.py @@ -21,6 +21,76 @@ from tests.common import flush_store from tests.typing import WebSocketGenerator +@pytest.fixture(name="entities") +def entities_fixture( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + request: pytest.FixtureRequest, +) -> dict[str, str]: + """Set up the test environment.""" + if request.param == "entities_unique_id": + return entities_unique_id(entity_registry) + elif request.param == "entities_no_unique_id": + return entities_no_unique_id(hass) + else: + raise RuntimeError("Invalid setup fixture") + + +def entities_unique_id(entity_registry: er.EntityRegistry) -> dict[str, str]: + """Create some entities in the entity registry.""" + entry_blocked = entity_registry.async_get_or_create( + "group", "test", "unique", suggested_object_id="all_locks" + ) + assert entry_blocked.entity_id == CLOUD_NEVER_EXPOSED_ENTITIES[0] + entry_lock = entity_registry.async_get_or_create("lock", "test", "unique1") + entry_binary_sensor = entity_registry.async_get_or_create( + "binary_sensor", "test", "unique1" + ) + entry_binary_sensor_door = entity_registry.async_get_or_create( + "binary_sensor", + "test", + "unique2", + original_device_class="door", + ) + entry_sensor = entity_registry.async_get_or_create("sensor", "test", "unique1") + entry_sensor_temperature = entity_registry.async_get_or_create( + "sensor", + "test", + "unique2", + original_device_class="temperature", + ) + return { + "blocked": entry_blocked.entity_id, + "lock": entry_lock.entity_id, + "binary_sensor": entry_binary_sensor.entity_id, + "door_sensor": entry_binary_sensor_door.entity_id, + "sensor": entry_sensor.entity_id, + "temperature_sensor": entry_sensor_temperature.entity_id, + } + + +def entities_no_unique_id(hass: HomeAssistant) -> dict[str, str]: + """Create some entities not in the entity registry.""" + blocked = CLOUD_NEVER_EXPOSED_ENTITIES[0] + lock = "lock.test" + binary_sensor = "binary_sensor.test" + door_sensor = "binary_sensor.door" + sensor = "sensor.test" + sensor_temperature = "sensor.temperature" + hass.states.async_set(binary_sensor, "on", {}) + hass.states.async_set(door_sensor, "on", {"device_class": "door"}) + hass.states.async_set(sensor, "on", {}) + hass.states.async_set(sensor_temperature, "on", {"device_class": "temperature"}) + return { + "blocked": blocked, + "lock": lock, + "binary_sensor": binary_sensor, + "door_sensor": door_sensor, + "sensor": sensor, + "temperature_sensor": sensor_temperature, + } + + async def test_load_preferences(hass: HomeAssistant) -> None: """Make sure that we can load/save data correctly.""" assert await async_setup_component(hass, "homeassistant", {}) @@ -31,13 +101,14 @@ async def test_load_preferences(hass: HomeAssistant) -> None: exposed_entities.async_set_expose_new_entities("test1", True) exposed_entities.async_set_expose_new_entities("test2", False) - assert list(exposed_entities._assistants) == ["test1", "test2"] - await exposed_entities.async_expose_entity("test1", "light.kitchen", True) await exposed_entities.async_expose_entity("test1", "light.living_room", True) await exposed_entities.async_expose_entity("test2", "light.kitchen", True) await exposed_entities.async_expose_entity("test2", "light.kitchen", True) + assert list(exposed_entities._assistants) == ["test1", "test2"] + assert list(exposed_entities.data) == ["light.kitchen", "light.living_room"] + await flush_store(exposed_entities.store) exposed_entities2 = ExposedEntities(hass) @@ -315,12 +386,14 @@ async def test_get_assistant_settings( exposed_entities.async_get_entity_settings("light.unknown") -@pytest.mark.parametrize("use_registry", [True, False]) +@pytest.mark.parametrize( + "entities", ["entities_unique_id", "entities_no_unique_id"], indirect=True +) async def test_should_expose( hass: HomeAssistant, entity_registry: er.EntityRegistry, hass_ws_client: WebSocketGenerator, - use_registry: bool, + entities: dict[str, str], ) -> None: """Test expose entity.""" ws_client = await hass_ws_client(hass) @@ -342,98 +415,81 @@ async def test_should_expose( assert await async_should_expose(hass, "test.test", "test.test") is False # Blocked entity is not exposed - if use_registry: - entry_blocked = entity_registry.async_get_or_create( - "group", "test", "unique", suggested_object_id="all_locks" - ) - assert entry_blocked.entity_id == "group.all_locks" - assert CLOUD_NEVER_EXPOSED_ENTITIES[0] == "group.all_locks" - assert await async_should_expose(hass, "cloud.alexa", "group.all_locks") is False + assert await async_should_expose(hass, "cloud.alexa", entities["blocked"]) is False # Lock is exposed - if use_registry: - entity_registry.async_get_or_create("lock", "test", "unique1") - assert await async_should_expose(hass, "cloud.alexa", "lock.test_unique1") is True - - # Hidden entity is not exposed - if use_registry: - entity_registry.async_get_or_create( - "lock", "test", "unique2", hidden_by=er.RegistryEntryHider.USER - ) - assert ( - await async_should_expose(hass, "cloud.alexa", "lock.test_unique2") is False - ) - - # Entity with category is not exposed - entity_registry.async_get_or_create( - "lock", "test", "unique3", entity_category=EntityCategory.CONFIG - ) - assert ( - await async_should_expose(hass, "cloud.alexa", "lock.test_unique3") is False - ) + assert await async_should_expose(hass, "cloud.alexa", entities["lock"]) is True # Binary sensor without device class is not exposed - if use_registry: - entity_registry.async_get_or_create("binary_sensor", "test", "unique1") - else: - hass.states.async_set("binary_sensor.test_unique1", "on", {}) assert ( - await async_should_expose(hass, "cloud.alexa", "binary_sensor.test_unique1") + await async_should_expose(hass, "cloud.alexa", entities["binary_sensor"]) is False ) # Binary sensor with certain device class is exposed - if use_registry: - entity_registry.async_get_or_create( - "binary_sensor", - "test", - "unique2", - original_device_class="door", - ) - else: - hass.states.async_set( - "binary_sensor.test_unique2", "on", {"device_class": "door"} - ) assert ( - await async_should_expose(hass, "cloud.alexa", "binary_sensor.test_unique2") - is True + await async_should_expose(hass, "cloud.alexa", entities["door_sensor"]) is True ) # Sensor without device class is not exposed - if use_registry: - entity_registry.async_get_or_create("sensor", "test", "unique1") - else: - hass.states.async_set("sensor.test_unique1", "on", {}) - assert ( - await async_should_expose(hass, "cloud.alexa", "sensor.test_unique1") is False - ) + assert await async_should_expose(hass, "cloud.alexa", entities["sensor"]) is False # Sensor with certain device class is exposed - if use_registry: - entity_registry.async_get_or_create( - "sensor", - "test", - "unique2", - original_device_class="temperature", - ) - else: - hass.states.async_set( - "sensor.test_unique2", "on", {"device_class": "temperature"} - ) - assert await async_should_expose(hass, "cloud.alexa", "sensor.test_unique2") is True + assert ( + await async_should_expose(hass, "cloud.alexa", entities["temperature_sensor"]) + is True + ) + # The second time we check, it should load it from storage - assert await async_should_expose(hass, "cloud.alexa", "sensor.test_unique2") is True + assert ( + await async_should_expose(hass, "cloud.alexa", entities["temperature_sensor"]) + is True + ) + # Check with a different assistant exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] exposed_entities.async_set_expose_new_entities("cloud.no_default_expose", False) assert ( await async_should_expose( - hass, "cloud.no_default_expose", "sensor.test_unique2" + hass, "cloud.no_default_expose", entities["temperature_sensor"] ) is False ) +async def test_should_expose_hidden_categorized( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test expose entity.""" + ws_client = await hass_ws_client(hass) + assert await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() + + # Expose new entities to Alexa + await ws_client.send_json_auto_id( + { + "type": "homeassistant/expose_new_entities/set", + "assistant": "cloud.alexa", + "expose_new": True, + } + ) + response = await ws_client.receive_json() + assert response["success"] + + entity_registry.async_get_or_create( + "lock", "test", "unique2", hidden_by=er.RegistryEntryHider.USER + ) + assert await async_should_expose(hass, "cloud.alexa", "lock.test_unique2") is False + + # Entity with category is not exposed + entity_registry.async_get_or_create( + "lock", "test", "unique3", entity_category=EntityCategory.CONFIG + ) + assert await async_should_expose(hass, "cloud.alexa", "lock.test_unique3") is False + + async def test_list_exposed_entities( hass: HomeAssistant, entity_registry: er.EntityRegistry, From 4860a8d1e8853609c13fc4e7d0f962f9804276cf Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 3 May 2023 12:39:22 +0200 Subject: [PATCH 141/822] Don't use storage collection helper in ExposedEntities (#92396) * Don't use storage collection helper in ExposedEntities * Fix tests --- homeassistant/components/alexa/config.py | 3 +- homeassistant/components/alexa/handlers.py | 2 +- homeassistant/components/alexa/messages.py | 4 +- homeassistant/components/alexa/smart_home.py | 2 +- .../components/alexa/smart_home_http.py | 3 +- .../components/alexa/state_report.py | 2 +- .../components/cloud/alexa_config.py | 9 +- .../components/cloud/google_config.py | 28 +-- .../components/conversation/__init__.py | 6 +- .../components/conversation/default_agent.py | 17 +- .../components/google_assistant/helpers.py | 16 +- .../components/google_assistant/http.py | 2 +- .../google_assistant/report_state.py | 4 +- .../components/google_assistant/smart_home.py | 6 +- .../components/homeassistant/__init__.py | 2 +- .../homeassistant/exposed_entities.py | 194 ++++++++---------- .../components/switch_as_x/__init__.py | 2 +- .../components/switch_as_x/entity.py | 8 +- tests/components/alexa/test_smart_home.py | 21 +- tests/components/cloud/test_alexa_config.py | 42 ++-- tests/components/cloud/test_client.py | 6 +- tests/components/cloud/test_google_config.py | 40 ++-- tests/components/conversation/__init__.py | 6 +- .../conversation/test_default_agent.py | 2 +- tests/components/conversation/test_init.py | 6 +- tests/components/google_assistant/__init__.py | 2 +- .../google_assistant/test_helpers.py | 6 +- .../components/google_assistant/test_http.py | 9 +- .../google_assistant/test_smart_home.py | 2 +- .../homeassistant/test_exposed_entities.py | 87 ++++---- tests/components/switch_as_x/test_init.py | 8 +- 31 files changed, 248 insertions(+), 299 deletions(-) diff --git a/homeassistant/components/alexa/config.py b/homeassistant/components/alexa/config.py index f1c4ad729c69..cdbea2ca346f 100644 --- a/homeassistant/components/alexa/config.py +++ b/homeassistant/components/alexa/config.py @@ -84,7 +84,8 @@ class AbstractConfig(ABC): unsub_func() self._unsub_proactive_report = None - async def should_expose(self, entity_id): + @callback + def should_expose(self, entity_id): """If an entity should be exposed.""" return False diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index ee9ef61787be..eb23b09627e6 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -103,7 +103,7 @@ async def async_api_discovery( discovery_endpoints = [ alexa_entity.serialize_discovery() for alexa_entity in async_get_entities(hass, config) - if await config.should_expose(alexa_entity.entity_id) + if config.should_expose(alexa_entity.entity_id) ] return directive.response( diff --git a/homeassistant/components/alexa/messages.py b/homeassistant/components/alexa/messages.py index 7aa929abf2ce..4dd154ea11f2 100644 --- a/homeassistant/components/alexa/messages.py +++ b/homeassistant/components/alexa/messages.py @@ -30,7 +30,7 @@ class AlexaDirective: self.entity = self.entity_id = self.endpoint = self.instance = None - async def load_entity(self, hass, config): + def load_entity(self, hass, config): """Set attributes related to the entity for this request. Sets these attributes when self.has_endpoint is True: @@ -49,7 +49,7 @@ class AlexaDirective: self.entity_id = _endpoint_id.replace("#", ".") self.entity = hass.states.get(self.entity_id) - if not self.entity or not await config.should_expose(self.entity_id): + if not self.entity or not config.should_expose(self.entity_id): raise AlexaInvalidEndpointError(_endpoint_id) self.endpoint = ENTITY_ADAPTERS[self.entity.domain](hass, config, self.entity) diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py index 6c2da5c01c10..242295078774 100644 --- a/homeassistant/components/alexa/smart_home.py +++ b/homeassistant/components/alexa/smart_home.py @@ -34,7 +34,7 @@ async def async_handle_message(hass, config, request, context=None, enabled=True await config.set_authorized(True) if directive.has_endpoint: - await directive.load_entity(hass, config) + directive.load_entity(hass, config) funct_ref = HANDLERS.get((directive.namespace, directive.name)) if funct_ref: diff --git a/homeassistant/components/alexa/smart_home_http.py b/homeassistant/components/alexa/smart_home_http.py index 5c7dd4d14026..3a702421d94a 100644 --- a/homeassistant/components/alexa/smart_home_http.py +++ b/homeassistant/components/alexa/smart_home_http.py @@ -60,7 +60,8 @@ class AlexaConfig(AbstractConfig): """Return an identifier for the user that represents this config.""" return "" - async def should_expose(self, entity_id): + @core.callback + def should_expose(self, entity_id): """If an entity should be exposed.""" if not self._config[CONF_FILTER].empty_filter: return self._config[CONF_FILTER](entity_id) diff --git a/homeassistant/components/alexa/state_report.py b/homeassistant/components/alexa/state_report.py index b9e1426bbc1d..a189c364c02d 100644 --- a/homeassistant/components/alexa/state_report.py +++ b/homeassistant/components/alexa/state_report.py @@ -64,7 +64,7 @@ async def async_enable_proactive_mode(hass, smart_home_config): if new_state.domain not in ENTITY_ADAPTERS: return - if not await smart_home_config.should_expose(changed_entity): + if not smart_home_config.should_expose(changed_entity): _LOGGER.debug("Not exposing %s because filtered by config", changed_entity) return diff --git a/homeassistant/components/cloud/alexa_config.py b/homeassistant/components/cloud/alexa_config.py index 06d6589204ba..212cbb26e0ab 100644 --- a/homeassistant/components/cloud/alexa_config.py +++ b/homeassistant/components/cloud/alexa_config.py @@ -257,14 +257,15 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): and entity_supported(self.hass, entity_id) ) - async def should_expose(self, entity_id): + @callback + def should_expose(self, entity_id): """If an entity should be exposed.""" if not self._config[CONF_FILTER].empty_filter: if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: return False return self._config[CONF_FILTER](entity_id) - return await async_should_expose(self.hass, CLOUD_ALEXA, entity_id) + return async_should_expose(self.hass, CLOUD_ALEXA, entity_id) @callback def async_invalidate_access_token(self): @@ -423,7 +424,7 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): is_enabled = self.enabled for entity in alexa_entities.async_get_entities(self.hass, self): - if is_enabled and await self.should_expose(entity.entity_id): + if is_enabled and self.should_expose(entity.entity_id): to_update.append(entity.entity_id) else: to_remove.append(entity.entity_id) @@ -482,7 +483,7 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): entity_id = event.data["entity_id"] - if not await self.should_expose(entity_id): + if not self.should_expose(entity_id): return action = event.data["action"] diff --git a/homeassistant/components/cloud/google_config.py b/homeassistant/components/cloud/google_config.py index 4c8ebfbd9e98..a57007891123 100644 --- a/homeassistant/components/cloud/google_config.py +++ b/homeassistant/components/cloud/google_config.py @@ -222,9 +222,9 @@ class CloudGoogleConfig(AbstractConfig): self._handle_device_registry_updated, ) - async def should_expose(self, state): + def should_expose(self, state): """If a state object should be exposed.""" - return await self._should_expose_entity_id(state.entity_id) + return self._should_expose_entity_id(state.entity_id) def _should_expose_legacy(self, entity_id): """If an entity ID should be exposed.""" @@ -258,14 +258,14 @@ class CloudGoogleConfig(AbstractConfig): and _supported_legacy(self.hass, entity_id) ) - async def _should_expose_entity_id(self, entity_id): + def _should_expose_entity_id(self, entity_id): """If an entity should be exposed.""" if not self._config[CONF_FILTER].empty_filter: if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: return False return self._config[CONF_FILTER](entity_id) - return await async_should_expose(self.hass, CLOUD_GOOGLE, entity_id) + return async_should_expose(self.hass, CLOUD_GOOGLE, entity_id) @property def agent_user_id(self): @@ -358,7 +358,8 @@ class CloudGoogleConfig(AbstractConfig): """Handle updated preferences.""" self.async_schedule_google_sync_all() - async def _handle_entity_registry_updated(self, event: Event) -> None: + @callback + def _handle_entity_registry_updated(self, event: Event) -> None: """Handle when entity registry updated.""" if ( not self.enabled @@ -375,11 +376,12 @@ class CloudGoogleConfig(AbstractConfig): entity_id = event.data["entity_id"] - if not await self._should_expose_entity_id(entity_id): + if not self._should_expose_entity_id(entity_id): return self.async_schedule_google_sync_all() + @callback async def _handle_device_registry_updated(self, event: Event) -> None: """Handle when device registry updated.""" if ( @@ -394,15 +396,13 @@ class CloudGoogleConfig(AbstractConfig): return # Check if any exposed entity uses the device area - used = False - for entity_entry in er.async_entries_for_device( - er.async_get(self.hass), event.data["device_id"] + if not any( + entity_entry.area_id is None + and self._should_expose_entity_id(entity_entry.entity_id) + for entity_entry in er.async_entries_for_device( + er.async_get(self.hass), event.data["device_id"] + ) ): - if entity_entry.area_id is None and await self._should_expose_entity_id( - entity_entry.entity_id - ): - used = True - if not used: return self.async_schedule_google_sync_all() diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index b27a6ebee02d..f156acfd568c 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -94,7 +94,7 @@ CONFIG_SCHEMA = vol.Schema( def _get_agent_manager(hass: HomeAssistant) -> AgentManager: """Get the active agent.""" manager = AgentManager(hass) - hass.async_create_task(manager.async_setup()) + manager.async_setup() return manager @@ -393,9 +393,9 @@ class AgentManager: self._agents: dict[str, AbstractConversationAgent] = {} self._builtin_agent_init_lock = asyncio.Lock() - async def async_setup(self) -> None: + def async_setup(self) -> None: """Set up the conversation agents.""" - await async_setup_default_agent(self.hass) + async_setup_default_agent(self.hass) async def async_get_agent( self, agent_id: str | None = None diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index d57de76f5e0f..d347140af2e2 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -73,20 +73,23 @@ def _get_language_variations(language: str) -> Iterable[str]: yield lang -async def async_setup(hass: core.HomeAssistant) -> None: +@core.callback +def async_setup(hass: core.HomeAssistant) -> None: """Set up entity registry listener for the default agent.""" entity_registry = er.async_get(hass) for entity_id in entity_registry.entities: - await async_should_expose(hass, DOMAIN, entity_id) + async_should_expose(hass, DOMAIN, entity_id) - async def async_handle_entity_registry_changed(event: core.Event) -> None: + @core.callback + def async_handle_entity_registry_changed(event: core.Event) -> None: """Set expose flag on newly created entities.""" if event.data["action"] == "create": - await async_should_expose(hass, DOMAIN, event.data["entity_id"]) + async_should_expose(hass, DOMAIN, event.data["entity_id"]) hass.bus.async_listen( er.EVENT_ENTITY_REGISTRY_UPDATED, async_handle_entity_registry_changed, + run_immediately=True, ) @@ -154,7 +157,7 @@ class DefaultAgent(AbstractConversationAgent): conversation_id, ) - slot_lists = await self._make_slot_lists() + slot_lists = self._make_slot_lists() result = await self.hass.async_add_executor_job( self._recognize, @@ -483,7 +486,7 @@ class DefaultAgent(AbstractConversationAgent): """Handle updated preferences.""" self._slot_lists = None - async def _make_slot_lists(self) -> dict[str, SlotList]: + def _make_slot_lists(self) -> dict[str, SlotList]: """Create slot lists with areas and entity names/aliases.""" if self._slot_lists is not None: return self._slot_lists @@ -493,7 +496,7 @@ class DefaultAgent(AbstractConversationAgent): entities = [ entity for entity in entity_registry.entities.values() - if await async_should_expose(self.hass, DOMAIN, entity.entity_id) + if async_should_expose(self.hass, DOMAIN, entity.entity_id) ] devices = dr.async_get(self.hass) diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index d192b2514dea..e194242df91a 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -175,7 +175,7 @@ class AbstractConfig(ABC): """Get agent user ID from context.""" @abstractmethod - async def should_expose(self, state) -> bool: + def should_expose(self, state) -> bool: """Return if entity should be exposed.""" def should_2fa(self, state): @@ -535,14 +535,16 @@ class GoogleEntity: ] return self._traits - async def should_expose(self): + @callback + def should_expose(self): """If entity should be exposed.""" - return await self.config.should_expose(self.state) + return self.config.should_expose(self.state) - async def should_expose_local(self) -> bool: + @callback + def should_expose_local(self) -> bool: """Return if the entity should be exposed locally.""" return ( - await self.should_expose() + self.should_expose() and get_google_type( self.state.domain, self.state.attributes.get(ATTR_DEVICE_CLASS) ) @@ -585,7 +587,7 @@ class GoogleEntity: trait.might_2fa(domain, features, device_class) for trait in self.traits() ) - async def sync_serialize(self, agent_user_id, instance_uuid): + def sync_serialize(self, agent_user_id, instance_uuid): """Serialize entity for a SYNC response. https://developers.google.com/actions/smarthome/create-app#actiondevicessync @@ -621,7 +623,7 @@ class GoogleEntity: device["name"]["nicknames"].extend(entity_entry.aliases) # Add local SDK info if enabled - if self.config.is_local_sdk_active and await self.should_expose_local(): + if self.config.is_local_sdk_active and self.should_expose_local(): device["otherDeviceIds"] = [{"deviceId": self.entity_id}] device["customData"] = { "webhookId": self.config.get_local_webhook_id(agent_user_id), diff --git a/homeassistant/components/google_assistant/http.py b/homeassistant/components/google_assistant/http.py index 4aadc9c40027..84d5e4a3364b 100644 --- a/homeassistant/components/google_assistant/http.py +++ b/homeassistant/components/google_assistant/http.py @@ -111,7 +111,7 @@ class GoogleConfig(AbstractConfig): """Return if states should be proactively reported.""" return self._config.get(CONF_REPORT_STATE) - async def should_expose(self, state) -> bool: + def should_expose(self, state) -> bool: """Return if entity should be exposed.""" expose_by_default = self._config.get(CONF_EXPOSE_BY_DEFAULT) exposed_domains = self._config.get(CONF_EXPOSED_DOMAINS) diff --git a/homeassistant/components/google_assistant/report_state.py b/homeassistant/components/google_assistant/report_state.py index c0a65cbfa7a8..737b54c8b1e9 100644 --- a/homeassistant/components/google_assistant/report_state.py +++ b/homeassistant/components/google_assistant/report_state.py @@ -63,7 +63,7 @@ def async_enable_report_state(hass: HomeAssistant, google_config: AbstractConfig if not new_state: return - if not await google_config.should_expose(new_state): + if not google_config.should_expose(new_state): return entity = GoogleEntity(hass, google_config, new_state) @@ -115,7 +115,7 @@ def async_enable_report_state(hass: HomeAssistant, google_config: AbstractConfig checker = await create_checker(hass, DOMAIN, extra_significant_check) for entity in async_get_entities(hass, google_config): - if not await entity.should_expose(): + if not entity.should_expose(): continue try: diff --git a/homeassistant/components/google_assistant/smart_home.py b/homeassistant/components/google_assistant/smart_home.py index 798743a447d5..1b1b443baac0 100644 --- a/homeassistant/components/google_assistant/smart_home.py +++ b/homeassistant/components/google_assistant/smart_home.py @@ -87,11 +87,11 @@ async def async_devices_sync_response(hass, config, agent_user_id): devices = [] for entity in entities: - if not await entity.should_expose(): + if not entity.should_expose(): continue try: - devices.append(await entity.sync_serialize(agent_user_id, instance_uuid)) + devices.append(entity.sync_serialize(agent_user_id, instance_uuid)) except Exception: # pylint: disable=broad-except _LOGGER.exception("Error serializing %s", entity.entity_id) @@ -318,7 +318,7 @@ async def async_devices_reachable( "devices": [ entity.reachable_device_serialize() for entity in async_get_entities(hass, data.config) - if entity.entity_id in google_ids and await entity.should_expose_local() + if entity.entity_id in google_ids and entity.should_expose_local() ] } diff --git a/homeassistant/components/homeassistant/__init__.py b/homeassistant/components/homeassistant/__init__.py index 45646b72b7f3..987a4317ba84 100644 --- a/homeassistant/components/homeassistant/__init__.py +++ b/homeassistant/components/homeassistant/__init__.py @@ -343,7 +343,7 @@ async def async_setup(hass: ha.HomeAssistant, config: ConfigType) -> bool: # no ) exposed_entities = ExposedEntities(hass) - await exposed_entities.async_load() + await exposed_entities.async_initialize() hass.data[DATA_EXPOSED_ENTITIES] = exposed_entities return True diff --git a/homeassistant/components/homeassistant/exposed_entities.py b/homeassistant/components/homeassistant/exposed_entities.py index 81b3a60b3f5f..56df611b3236 100644 --- a/homeassistant/components/homeassistant/exposed_entities.py +++ b/homeassistant/components/homeassistant/exposed_entities.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable, Mapping import dataclasses from itertools import chain -from typing import Any +from typing import Any, TypedDict import voluptuous as vol @@ -15,11 +15,6 @@ from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES from homeassistant.core import HomeAssistant, callback, split_entity_id from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.collection import ( - IDManager, - SerializedStorageCollection, - StorageCollection, -) from homeassistant.helpers.entity import get_device_class from homeassistant.helpers.storage import Store @@ -89,30 +84,21 @@ class ExposedEntity: assistants: dict[str, dict[str, Any]] - def to_json(self, entity_id: str) -> dict[str, Any]: + def to_json(self) -> dict[str, Any]: """Return a JSON serializable representation for storage.""" return { "assistants": self.assistants, - "id": entity_id, } -class SerializedExposedEntities(SerializedStorageCollection): +class SerializedExposedEntities(TypedDict): """Serialized exposed entities storage storage collection.""" assistants: dict[str, dict[str, Any]] + exposed_entities: dict[str, dict[str, Any]] -class ExposedEntitiesIDManager(IDManager): - """ID manager for tags.""" - - def generate_id(self, suggestion: str) -> str: - """Generate an ID.""" - assert not self.has_id(suggestion) - return suggestion - - -class ExposedEntities(StorageCollection[ExposedEntity, SerializedExposedEntities]): +class ExposedEntities: """Control assistant settings. Settings for entities without a unique_id are stored in the store. @@ -120,21 +106,23 @@ class ExposedEntities(StorageCollection[ExposedEntity, SerializedExposedEntities """ _assistants: dict[str, AssistantPreferences] + entities: dict[str, ExposedEntity] def __init__(self, hass: HomeAssistant) -> None: """Initialize.""" - super().__init__( - Store(hass, STORAGE_VERSION, STORAGE_KEY), ExposedEntitiesIDManager() - ) + self._hass = hass self._listeners: dict[str, list[Callable[[], None]]] = {} + self._store: Store[SerializedExposedEntities] = Store( + hass, STORAGE_VERSION, STORAGE_KEY + ) - async def async_load(self) -> None: + async def async_initialize(self) -> None: """Finish initializing.""" - await super().async_load() - websocket_api.async_register_command(self.hass, ws_expose_entity) - websocket_api.async_register_command(self.hass, ws_expose_new_entities_get) - websocket_api.async_register_command(self.hass, ws_expose_new_entities_set) - websocket_api.async_register_command(self.hass, ws_list_exposed_entities) + websocket_api.async_register_command(self._hass, ws_expose_entity) + websocket_api.async_register_command(self._hass, ws_expose_new_entities_get) + websocket_api.async_register_command(self._hass, ws_expose_new_entities_set) + websocket_api.async_register_command(self._hass, ws_list_exposed_entities) + await self._async_load_data() @callback def async_listen_entity_updates( @@ -143,18 +131,17 @@ class ExposedEntities(StorageCollection[ExposedEntity, SerializedExposedEntities """Listen for updates to entity expose settings.""" self._listeners.setdefault(assistant, []).append(listener) - async def async_expose_entity( + @callback + def async_expose_entity( self, assistant: str, entity_id: str, should_expose: bool ) -> None: """Expose an entity to an assistant. Notify listeners if expose flag was changed. """ - entity_registry = er.async_get(self.hass) + entity_registry = er.async_get(self._hass) if not (registry_entry := entity_registry.async_get(entity_id)): - return await self._async_expose_legacy_entity( - assistant, entity_id, should_expose - ) + return self._async_expose_legacy_entity(assistant, entity_id, should_expose) assistant_options: Mapping[str, Any] if ( @@ -169,7 +156,7 @@ class ExposedEntities(StorageCollection[ExposedEntity, SerializedExposedEntities for listener in self._listeners.get(assistant, []): listener() - async def _async_expose_legacy_entity( + def _async_expose_legacy_entity( self, assistant: str, entity_id: str, should_expose: bool ) -> None: """Expose an entity to an assistant. @@ -177,23 +164,20 @@ class ExposedEntities(StorageCollection[ExposedEntity, SerializedExposedEntities Notify listeners if expose flag was changed. """ if ( - (exposed_entity := self.data.get(entity_id)) + (exposed_entity := self.entities.get(entity_id)) and (assistant_options := exposed_entity.assistants.get(assistant, {})) and assistant_options.get("should_expose") == should_expose ): return if exposed_entity: - await self.async_update_item( - entity_id, {"assistants": {assistant: {"should_expose": should_expose}}} + new_exposed_entity = self._update_exposed_entity( + assistant, entity_id, should_expose ) else: - await self.async_create_item( - { - "entity_id": entity_id, - "assistants": {assistant: {"should_expose": should_expose}}, - } - ) + new_exposed_entity = self._new_exposed_entity(assistant, should_expose) + self.entities[entity_id] = new_exposed_entity + self._async_schedule_save() for listener in self._listeners.get(assistant, []): listener() @@ -215,11 +199,11 @@ class ExposedEntities(StorageCollection[ExposedEntity, SerializedExposedEntities self, assistant: str ) -> dict[str, Mapping[str, Any]]: """Get all entity expose settings for an assistant.""" - entity_registry = er.async_get(self.hass) + entity_registry = er.async_get(self._hass) result: dict[str, Mapping[str, Any]] = {} options: Mapping | None - for entity_id, exposed_entity in self.data.items(): + for entity_id, exposed_entity in self.entities.items(): if options := exposed_entity.assistants.get(assistant): result[entity_id] = options @@ -232,13 +216,13 @@ class ExposedEntities(StorageCollection[ExposedEntity, SerializedExposedEntities @callback def async_get_entity_settings(self, entity_id: str) -> dict[str, Mapping[str, Any]]: """Get assistant expose settings for an entity.""" - entity_registry = er.async_get(self.hass) + entity_registry = er.async_get(self._hass) result: dict[str, Mapping[str, Any]] = {} assistant_settings: Mapping if registry_entry := entity_registry.async_get(entity_id): assistant_settings = registry_entry.options - elif exposed_entity := self.data.get(entity_id): + elif exposed_entity := self.entities.get(entity_id): assistant_settings = exposed_entity.assistants else: raise HomeAssistantError("Unknown entity") @@ -249,16 +233,17 @@ class ExposedEntities(StorageCollection[ExposedEntity, SerializedExposedEntities return result - async def async_should_expose(self, assistant: str, entity_id: str) -> bool: + @callback + def async_should_expose(self, assistant: str, entity_id: str) -> bool: """Return True if an entity should be exposed to an assistant.""" should_expose: bool if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: return False - entity_registry = er.async_get(self.hass) + entity_registry = er.async_get(self._hass) if not (registry_entry := entity_registry.async_get(entity_id)): - return await self._async_should_expose_legacy_entity(assistant, entity_id) + return self._async_should_expose_legacy_entity(assistant, entity_id) if assistant in registry_entry.options: if "should_expose" in registry_entry.options[assistant]: should_expose = registry_entry.options[assistant]["should_expose"] @@ -277,14 +262,14 @@ class ExposedEntities(StorageCollection[ExposedEntity, SerializedExposedEntities return should_expose - async def _async_should_expose_legacy_entity( + def _async_should_expose_legacy_entity( self, assistant: str, entity_id: str ) -> bool: """Return True if an entity should be exposed to an assistant.""" should_expose: bool if ( - exposed_entity := self.data.get(entity_id) + exposed_entity := self.entities.get(entity_id) ) and assistant in exposed_entity.assistants: if "should_expose" in exposed_entity.assistants[assistant]: should_expose = exposed_entity.assistants[assistant]["should_expose"] @@ -296,16 +281,13 @@ class ExposedEntities(StorageCollection[ExposedEntity, SerializedExposedEntities should_expose = False if exposed_entity: - await self.async_update_item( - entity_id, {"assistants": {assistant: {"should_expose": should_expose}}} + new_exposed_entity = self._update_exposed_entity( + assistant, entity_id, should_expose ) else: - await self.async_create_item( - { - "entity_id": entity_id, - "assistants": {assistant: {"should_expose": should_expose}}, - } - ) + new_exposed_entity = self._new_exposed_entity(assistant, should_expose) + self.entities[entity_id] = new_exposed_entity + self._async_schedule_save() return should_expose @@ -323,7 +305,7 @@ class ExposedEntities(StorageCollection[ExposedEntity, SerializedExposedEntities if domain in DEFAULT_EXPOSED_DOMAINS: return True - device_class = get_device_class(self.hass, entity_id) + device_class = get_device_class(self._hass, entity_id) if ( domain == "binary_sensor" and device_class in DEFAULT_EXPOSED_BINARY_SENSOR_DEVICE_CLASSES @@ -335,73 +317,66 @@ class ExposedEntities(StorageCollection[ExposedEntity, SerializedExposedEntities return False - async def _process_create_data(self, data: dict) -> dict: - """Validate the config is valid.""" - return data - - @callback - def _get_suggested_id(self, info: dict) -> str: - """Suggest an ID based on the config.""" - entity_id: str = info["entity_id"] - return entity_id - - async def _update_data( - self, item: ExposedEntity, update_data: dict + def _update_exposed_entity( + self, + assistant: str, + entity_id: str, + should_expose: bool, ) -> ExposedEntity: - """Return a new updated item.""" - new_assistant_settings: dict[str, Any] = update_data["assistants"] - old_assistant_settings = item.assistants - for assistant, old_settings in old_assistant_settings.items(): - new_settings = new_assistant_settings.get(assistant, {}) - new_assistant_settings[assistant] = old_settings | new_settings - return dataclasses.replace(item, assistants=new_assistant_settings) + """Update an exposed entity.""" + entity = self.entities[entity_id] + assistants = dict(entity.assistants) + old_settings = assistants.get(assistant, {}) + assistants[assistant] = old_settings | {"should_expose": should_expose} + return ExposedEntity(assistants) - def _create_item(self, item_id: str, data: dict) -> ExposedEntity: - """Create an item from validated config.""" + def _new_exposed_entity(self, assistant: str, should_expose: bool) -> ExposedEntity: + """Create a new exposed entity.""" return ExposedEntity( - assistants=data["assistants"], + assistants={assistant: {"should_expose": should_expose}}, ) - def _deserialize_item(self, data: dict) -> ExposedEntity: - """Create an item from its serialized representation.""" - return ExposedEntity( - assistants=data["assistants"], - ) - - def _serialize_item(self, item_id: str, item: ExposedEntity) -> dict: - """Return the serialized representation of an item for storing.""" - return item.to_json(item_id) - async def _async_load_data(self) -> SerializedExposedEntities | None: """Load from the store.""" - data = await super()._async_load_data() + data = await self._store.async_load() assistants: dict[str, AssistantPreferences] = {} + exposed_entities: dict[str, ExposedEntity] = {} - if data and "assistants" in data: + if data: for domain, preferences in data["assistants"].items(): assistants[domain] = AssistantPreferences(**preferences) - self._assistants = assistants + if data and "exposed_entities" in data: + for entity_id, preferences in data["exposed_entities"].items(): + exposed_entities[entity_id] = ExposedEntity(**preferences) - if data and "items" not in data: - return None # type: ignore[unreachable] + self._assistants = assistants + self.entities = exposed_entities return data + @callback + def _async_schedule_save(self) -> None: + """Schedule saving the preferences.""" + self._store.async_delay_save(self._data_to_save, SAVE_DELAY) + @callback def _data_to_save(self) -> SerializedExposedEntities: """Return JSON-compatible date for storing to file.""" - base_data = super()._base_data_to_save() return { - "items": base_data["items"], "assistants": { domain: preferences.to_json() for domain, preferences in self._assistants.items() }, + "exposed_entities": { + entity_id: entity.to_json() + for entity_id, entity in self.entities.items() + }, } +@callback @websocket_api.require_admin @websocket_api.websocket_command( { @@ -411,8 +386,7 @@ class ExposedEntities(StorageCollection[ExposedEntity, SerializedExposedEntities vol.Required("should_expose"): bool, } ) -@websocket_api.async_response -async def ws_expose_entity( +def ws_expose_entity( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: """Expose an entity to an assistant.""" @@ -434,7 +408,7 @@ async def ws_expose_entity( exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] for entity_id in entity_ids: for assistant in msg["assistants"]: - await exposed_entities.async_expose_entity( + exposed_entities.async_expose_entity( assistant, entity_id, msg["should_expose"] ) connection.send_result(msg["id"]) @@ -455,7 +429,7 @@ def ws_list_exposed_entities( exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] entity_registry = er.async_get(hass) - for entity_id in chain(exposed_entities.data, entity_registry.entities): + for entity_id in chain(exposed_entities.entities, entity_registry.entities): result[entity_id] = {} entity_settings = async_get_entity_settings(hass, entity_id) for assistant, settings in entity_settings.items(): @@ -527,7 +501,8 @@ def async_get_entity_settings( return exposed_entities.async_get_entity_settings(entity_id) -async def async_expose_entity( +@callback +def async_expose_entity( hass: HomeAssistant, assistant: str, entity_id: str, @@ -535,12 +510,11 @@ async def async_expose_entity( ) -> None: """Get assistant expose settings for an entity.""" exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] - await exposed_entities.async_expose_entity(assistant, entity_id, should_expose) + exposed_entities.async_expose_entity(assistant, entity_id, should_expose) -async def async_should_expose( - hass: HomeAssistant, assistant: str, entity_id: str -) -> bool: +@callback +def async_should_expose(hass: HomeAssistant, assistant: str, entity_id: str) -> bool: """Return True if an entity should be exposed to an assistant.""" exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] - return await exposed_entities.async_should_expose(assistant, entity_id) + return exposed_entities.async_should_expose(assistant, entity_id) diff --git a/homeassistant/components/switch_as_x/__init__.py b/homeassistant/components/switch_as_x/__init__.py index 6e6dffd23377..ef64a86c6e8a 100644 --- a/homeassistant/components/switch_as_x/__init__.py +++ b/homeassistant/components/switch_as_x/__init__.py @@ -138,6 +138,6 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: for assistant, settings in expose_settings.items(): if (should_expose := settings.get("should_expose")) is None: continue - await exposed_entities.async_expose_entity( + exposed_entities.async_expose_entity( hass, assistant, switch_entity_id, should_expose ) diff --git a/homeassistant/components/switch_as_x/entity.py b/homeassistant/components/switch_as_x/entity.py index d2e3995b85e2..a73271bdc835 100644 --- a/homeassistant/components/switch_as_x/entity.py +++ b/homeassistant/components/switch_as_x/entity.py @@ -111,7 +111,7 @@ class BaseEntity(Entity): return registry.async_update_entity(self.entity_id, name=wrapped_switch.name) - async def copy_expose_settings() -> None: + def copy_expose_settings() -> None: """Copy assistant expose settings from the wrapped entity. Also unexpose the wrapped entity if exposed. @@ -122,15 +122,15 @@ class BaseEntity(Entity): for assistant, settings in expose_settings.items(): if (should_expose := settings.get("should_expose")) is None: continue - await exposed_entities.async_expose_entity( + exposed_entities.async_expose_entity( self.hass, assistant, self.entity_id, should_expose ) - await exposed_entities.async_expose_entity( + exposed_entities.async_expose_entity( self.hass, assistant, self._switch_entity_id, False ) copy_custom_name(wrapped_switch) - await copy_expose_settings() + copy_expose_settings() class BaseToggleEntity(BaseEntity, ToggleEntity): diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 36cc005bf2f2..601f59fd1187 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -2450,18 +2450,13 @@ async def test_exclude_filters(hass: HomeAssistant) -> None: hass.states.async_set("cover.deny", "off", {"friendly_name": "Blocked cover"}) alexa_config = MockConfig(hass) - filter = entityfilter.generate_filter( + alexa_config.should_expose = entityfilter.generate_filter( include_domains=[], include_entities=[], exclude_domains=["script"], exclude_entities=["cover.deny"], ) - async def mock_should_expose(entity_id): - return filter(entity_id) - - alexa_config.should_expose = mock_should_expose - msg = await smart_home.async_handle_message(hass, alexa_config, request) await hass.async_block_till_done() @@ -2486,18 +2481,13 @@ async def test_include_filters(hass: HomeAssistant) -> None: hass.states.async_set("group.allow", "off", {"friendly_name": "Allowed group"}) alexa_config = MockConfig(hass) - filter = entityfilter.generate_filter( + alexa_config.should_expose = entityfilter.generate_filter( include_domains=["automation", "group"], include_entities=["script.deny"], exclude_domains=[], exclude_entities=[], ) - async def mock_should_expose(entity_id): - return filter(entity_id) - - alexa_config.should_expose = mock_should_expose - msg = await smart_home.async_handle_message(hass, alexa_config, request) await hass.async_block_till_done() @@ -2516,18 +2506,13 @@ async def test_never_exposed_entities(hass: HomeAssistant) -> None: hass.states.async_set("group.allow", "off", {"friendly_name": "Allowed group"}) alexa_config = MockConfig(hass) - filter = entityfilter.generate_filter( + alexa_config.should_expose = entityfilter.generate_filter( include_domains=["group"], include_entities=[], exclude_domains=[], exclude_entities=[], ) - async def mock_should_expose(entity_id): - return filter(entity_id) - - alexa_config.should_expose = mock_should_expose - msg = await smart_home.async_handle_message(hass, alexa_config, request) await hass.async_block_till_done() diff --git a/tests/components/cloud/test_alexa_config.py b/tests/components/cloud/test_alexa_config.py index bf4890e92dd8..257d04cc697b 100644 --- a/tests/components/cloud/test_alexa_config.py +++ b/tests/components/cloud/test_alexa_config.py @@ -38,10 +38,10 @@ def expose_new(hass, expose_new): exposed_entities.async_set_expose_new_entities("cloud.alexa", expose_new) -async def expose_entity(hass, entity_id, should_expose): +def expose_entity(hass, entity_id, should_expose): """Expose an entity to Alexa.""" exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] - await exposed_entities.async_expose_entity("cloud.alexa", entity_id, should_expose) + exposed_entities.async_expose_entity("cloud.alexa", entity_id, should_expose) async def test_alexa_config_expose_entity_prefs( @@ -95,35 +95,35 @@ async def test_alexa_config_expose_entity_prefs( alexa_report_state=False, ) expose_new(hass, True) - await expose_entity(hass, entity_entry5.entity_id, False) + expose_entity(hass, entity_entry5.entity_id, False) conf = alexa_config.CloudAlexaConfig( hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, cloud_stub ) await conf.async_initialize() # an entity which is not in the entity registry can be exposed - await expose_entity(hass, "light.kitchen", True) - assert await conf.should_expose("light.kitchen") + expose_entity(hass, "light.kitchen", True) + assert conf.should_expose("light.kitchen") # categorized and hidden entities should not be exposed - assert not await conf.should_expose(entity_entry1.entity_id) - assert not await conf.should_expose(entity_entry2.entity_id) - assert not await conf.should_expose(entity_entry3.entity_id) - assert not await conf.should_expose(entity_entry4.entity_id) + assert not conf.should_expose(entity_entry1.entity_id) + assert not conf.should_expose(entity_entry2.entity_id) + assert not conf.should_expose(entity_entry3.entity_id) + assert not conf.should_expose(entity_entry4.entity_id) # this has been hidden - assert not await conf.should_expose(entity_entry5.entity_id) + assert not conf.should_expose(entity_entry5.entity_id) # exposed by default - assert await conf.should_expose(entity_entry6.entity_id) + assert conf.should_expose(entity_entry6.entity_id) - await expose_entity(hass, entity_entry5.entity_id, True) - assert await conf.should_expose(entity_entry5.entity_id) + expose_entity(hass, entity_entry5.entity_id, True) + assert conf.should_expose(entity_entry5.entity_id) - await expose_entity(hass, entity_entry5.entity_id, None) - assert not await conf.should_expose(entity_entry5.entity_id) + expose_entity(hass, entity_entry5.entity_id, None) + assert not conf.should_expose(entity_entry5.entity_id) assert "alexa" not in hass.config.components await hass.async_block_till_done() assert "alexa" in hass.config.components - assert not await conf.should_expose(entity_entry5.entity_id) + assert not conf.should_expose(entity_entry5.entity_id) async def test_alexa_config_report_state( @@ -368,7 +368,7 @@ async def test_alexa_update_expose_trigger_sync( await conf.async_initialize() with patch_sync_helper() as (to_update, to_remove): - await expose_entity(hass, light_entry.entity_id, True) + expose_entity(hass, light_entry.entity_id, True) await hass.async_block_till_done() async_fire_time_changed(hass, fire_all=True) await hass.async_block_till_done() @@ -378,9 +378,9 @@ async def test_alexa_update_expose_trigger_sync( assert to_remove == [] with patch_sync_helper() as (to_update, to_remove): - await expose_entity(hass, light_entry.entity_id, False) - await expose_entity(hass, binary_sensor_entry.entity_id, True) - await expose_entity(hass, sensor_entry.entity_id, True) + expose_entity(hass, light_entry.entity_id, False) + expose_entity(hass, binary_sensor_entry.entity_id, True) + expose_entity(hass, sensor_entry.entity_id, True) await hass.async_block_till_done() async_fire_time_changed(hass, fire_all=True) await hass.async_block_till_done() @@ -586,7 +586,7 @@ async def test_alexa_config_migrate_expose_entity_prefs( alexa_report_state=False, alexa_settings_version=1, ) - await expose_entity(hass, entity_migrated.entity_id, False) + expose_entity(hass, entity_migrated.entity_id, False) cloud_prefs._prefs[PREF_ALEXA_ENTITY_CONFIGS]["light.unknown"] = { PREF_SHOULD_EXPOSE: True diff --git a/tests/components/cloud/test_client.py b/tests/components/cloud/test_client.py index 9bca4b793402..d1e1a8ce112a 100644 --- a/tests/components/cloud/test_client.py +++ b/tests/components/cloud/test_client.py @@ -265,13 +265,13 @@ async def test_google_config_expose_entity( state = State(entity_entry.entity_id, "on") gconf = await cloud_client.get_google_config() - assert await gconf.should_expose(state) + assert gconf.should_expose(state) - await exposed_entities.async_expose_entity( + exposed_entities.async_expose_entity( "cloud.google_assistant", entity_entry.entity_id, False ) - assert not await gconf.should_expose(state) + assert not gconf.should_expose(state) async def test_google_config_should_2fa( diff --git a/tests/components/cloud/test_google_config.py b/tests/components/cloud/test_google_config.py index 51e8de983010..877a6efaf05e 100644 --- a/tests/components/cloud/test_google_config.py +++ b/tests/components/cloud/test_google_config.py @@ -46,10 +46,10 @@ def expose_new(hass, expose_new): exposed_entities.async_set_expose_new_entities("cloud.google_assistant", expose_new) -async def expose_entity(hass, entity_id, should_expose): +def expose_entity(hass, entity_id, should_expose): """Expose an entity to Google.""" exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] - await exposed_entities.async_expose_entity( + exposed_entities.async_expose_entity( "cloud.google_assistant", entity_id, should_expose ) @@ -150,7 +150,7 @@ async def test_google_update_expose_trigger_sync( with patch.object(config, "async_sync_entities") as mock_sync, patch.object( ga_helpers, "SYNC_DELAY", 0 ): - await expose_entity(hass, light_entry.entity_id, True) + expose_entity(hass, light_entry.entity_id, True) await hass.async_block_till_done() async_fire_time_changed(hass, utcnow()) await hass.async_block_till_done() @@ -160,9 +160,9 @@ async def test_google_update_expose_trigger_sync( with patch.object(config, "async_sync_entities") as mock_sync, patch.object( ga_helpers, "SYNC_DELAY", 0 ): - await expose_entity(hass, light_entry.entity_id, False) - await expose_entity(hass, binary_sensor_entry.entity_id, True) - await expose_entity(hass, sensor_entry.entity_id, True) + expose_entity(hass, light_entry.entity_id, False) + expose_entity(hass, binary_sensor_entry.entity_id, True) + expose_entity(hass, sensor_entry.entity_id, True) await hass.async_block_till_done() async_fire_time_changed(hass, utcnow()) await hass.async_block_till_done() @@ -384,7 +384,7 @@ async def test_google_config_expose_entity_prefs( ) expose_new(hass, True) - await expose_entity(hass, entity_entry5.entity_id, False) + expose_entity(hass, entity_entry5.entity_id, False) state = State("light.kitchen", "on") state_config = State(entity_entry1.entity_id, "on") @@ -395,23 +395,23 @@ async def test_google_config_expose_entity_prefs( state_exposed_default = State(entity_entry6.entity_id, "on") # an entity which is not in the entity registry can be exposed - await expose_entity(hass, "light.kitchen", True) - assert await mock_conf.should_expose(state) + expose_entity(hass, "light.kitchen", True) + assert mock_conf.should_expose(state) # categorized and hidden entities should not be exposed - assert not await mock_conf.should_expose(state_config) - assert not await mock_conf.should_expose(state_diagnostic) - assert not await mock_conf.should_expose(state_hidden_integration) - assert not await mock_conf.should_expose(state_hidden_user) + assert not mock_conf.should_expose(state_config) + assert not mock_conf.should_expose(state_diagnostic) + assert not mock_conf.should_expose(state_hidden_integration) + assert not mock_conf.should_expose(state_hidden_user) # this has been hidden - assert not await mock_conf.should_expose(state_not_exposed) + assert not mock_conf.should_expose(state_not_exposed) # exposed by default - assert await mock_conf.should_expose(state_exposed_default) + assert mock_conf.should_expose(state_exposed_default) - await expose_entity(hass, entity_entry5.entity_id, True) - assert await mock_conf.should_expose(state_not_exposed) + expose_entity(hass, entity_entry5.entity_id, True) + assert mock_conf.should_expose(state_not_exposed) - await expose_entity(hass, entity_entry5.entity_id, None) - assert not await mock_conf.should_expose(state_not_exposed) + expose_entity(hass, entity_entry5.entity_id, None) + assert not mock_conf.should_expose(state_not_exposed) def test_enabled_requires_valid_sub( @@ -535,7 +535,7 @@ async def test_google_config_migrate_expose_entity_prefs( google_report_state=False, google_settings_version=1, ) - await expose_entity(hass, entity_migrated.entity_id, False) + expose_entity(hass, entity_migrated.entity_id, False) cloud_prefs._prefs[PREF_GOOGLE_ENTITY_CONFIGS]["light.unknown"] = { PREF_SHOULD_EXPOSE: True diff --git a/tests/components/conversation/__init__.py b/tests/components/conversation/__init__.py index fb455da945b4..6eadb0680541 100644 --- a/tests/components/conversation/__init__.py +++ b/tests/components/conversation/__init__.py @@ -51,9 +51,7 @@ def expose_new(hass, expose_new): exposed_entities.async_set_expose_new_entities(conversation.DOMAIN, expose_new) -async def expose_entity(hass, entity_id, should_expose): +def expose_entity(hass, entity_id, should_expose): """Expose an entity to the default agent.""" exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] - await exposed_entities.async_expose_entity( - conversation.DOMAIN, entity_id, should_expose - ) + exposed_entities.async_expose_entity(conversation.DOMAIN, entity_id, should_expose) diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index daba360f7bf5..44bb31119879 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -108,7 +108,7 @@ async def test_exposed_areas( hass.states.async_set(bedroom_light.entity_id, "on") # Hide the bedroom light - await expose_entity(hass, bedroom_light.entity_id, False) + expose_entity(hass, bedroom_light.entity_id, False) result = await conversation.async_converse( hass, "turn on lights in the kitchen", None, Context(), None diff --git a/tests/components/conversation/test_init.py b/tests/components/conversation/test_init.py index b6bb4aad68fd..6b981cbbdfa8 100644 --- a/tests/components/conversation/test_init.py +++ b/tests/components/conversation/test_init.py @@ -680,7 +680,7 @@ async def test_http_processing_intent_entity_exposed( } # Unexpose the entity - await expose_entity(hass, "light.kitchen", False) + expose_entity(hass, "light.kitchen", False) await hass.async_block_till_done() client = await hass_client() @@ -730,7 +730,7 @@ async def test_http_processing_intent_entity_exposed( } # Now expose the entity - await expose_entity(hass, "light.kitchen", True) + expose_entity(hass, "light.kitchen", True) await hass.async_block_till_done() client = await hass_client() @@ -845,7 +845,7 @@ async def test_http_processing_intent_conversion_not_expose_new( } # Expose the entity - await expose_entity(hass, "light.kitchen", True) + expose_entity(hass, "light.kitchen", True) await hass.async_block_till_done() resp = await client.post( diff --git a/tests/components/google_assistant/__init__.py b/tests/components/google_assistant/__init__.py index 29ad5e6710af..2122818bbb44 100644 --- a/tests/components/google_assistant/__init__.py +++ b/tests/components/google_assistant/__init__.py @@ -58,7 +58,7 @@ class MockConfig(helpers.AbstractConfig): """Get agent user ID making request.""" return context.user_id - async def should_expose(self, state): + def should_expose(self, state): """Expose it all.""" return self._should_expose is None or self._should_expose(state) diff --git a/tests/components/google_assistant/test_helpers.py b/tests/components/google_assistant/test_helpers.py index cda3ded25be3..793db076c79c 100644 --- a/tests/components/google_assistant/test_helpers.py +++ b/tests/components/google_assistant/test_helpers.py @@ -49,13 +49,13 @@ async def test_google_entity_sync_serialize_with_local_sdk(hass: HomeAssistant) ) entity = helpers.GoogleEntity(hass, config, hass.states.get("light.ceiling_lights")) - serialized = await entity.sync_serialize(None, "mock-uuid") + serialized = entity.sync_serialize(None, "mock-uuid") assert "otherDeviceIds" not in serialized assert "customData" not in serialized config.async_enable_local_sdk() - serialized = await entity.sync_serialize("mock-user-id", "abcdef") + serialized = entity.sync_serialize("mock-user-id", "abcdef") assert serialized["otherDeviceIds"] == [{"deviceId": "light.ceiling_lights"}] assert serialized["customData"] == { "httpPort": 1234, @@ -68,7 +68,7 @@ async def test_google_entity_sync_serialize_with_local_sdk(hass: HomeAssistant) "homeassistant.components.google_assistant.helpers.get_google_type", return_value=device_type, ): - serialized = await entity.sync_serialize(None, "mock-uuid") + serialized = entity.sync_serialize(None, "mock-uuid") assert "otherDeviceIds" not in serialized assert "customData" not in serialized diff --git a/tests/components/google_assistant/test_http.py b/tests/components/google_assistant/test_http.py index 5297d6b29e53..b7dc880ede07 100644 --- a/tests/components/google_assistant/test_http.py +++ b/tests/components/google_assistant/test_http.py @@ -257,9 +257,7 @@ async def test_should_expose(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert ( - await config.should_expose( - State(DOMAIN + ".mock", "mock", {"view": "not None"}) - ) + config.should_expose(State(DOMAIN + ".mock", "mock", {"view": "not None"})) is False ) @@ -267,10 +265,7 @@ async def test_should_expose(hass: HomeAssistant) -> None: # Wait for google_assistant.helpers.async_initialize.sync_google to be called await hass.async_block_till_done() - assert ( - await config.should_expose(State(CLOUD_NEVER_EXPOSED_ENTITIES[0], "mock")) - is False - ) + assert config.should_expose(State(CLOUD_NEVER_EXPOSED_ENTITIES[0], "mock")) is False async def test_missing_service_account(hass: HomeAssistant) -> None: diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index ff673ddfe240..6455128fce8b 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -909,7 +909,7 @@ async def test_serialize_input_boolean(hass: HomeAssistant) -> None: """Test serializing an input boolean entity.""" state = State("input_boolean.bla", "on") entity = sh.GoogleEntity(hass, BASIC_CONFIG, state) - result = await entity.sync_serialize(None, "mock-uuid") + result = entity.sync_serialize(None, "mock-uuid") assert result == { "id": "input_boolean.bla", "attributes": {}, diff --git a/tests/components/homeassistant/test_exposed_entities.py b/tests/components/homeassistant/test_exposed_entities.py index 4f9a78625dbc..db82a6961556 100644 --- a/tests/components/homeassistant/test_exposed_entities.py +++ b/tests/components/homeassistant/test_exposed_entities.py @@ -101,21 +101,21 @@ async def test_load_preferences(hass: HomeAssistant) -> None: exposed_entities.async_set_expose_new_entities("test1", True) exposed_entities.async_set_expose_new_entities("test2", False) - await exposed_entities.async_expose_entity("test1", "light.kitchen", True) - await exposed_entities.async_expose_entity("test1", "light.living_room", True) - await exposed_entities.async_expose_entity("test2", "light.kitchen", True) - await exposed_entities.async_expose_entity("test2", "light.kitchen", True) + exposed_entities.async_expose_entity("test1", "light.kitchen", True) + exposed_entities.async_expose_entity("test1", "light.living_room", True) + exposed_entities.async_expose_entity("test2", "light.kitchen", True) + exposed_entities.async_expose_entity("test2", "light.kitchen", True) assert list(exposed_entities._assistants) == ["test1", "test2"] - assert list(exposed_entities.data) == ["light.kitchen", "light.living_room"] + assert list(exposed_entities.entities) == ["light.kitchen", "light.living_room"] - await flush_store(exposed_entities.store) + await flush_store(exposed_entities._store) exposed_entities2 = ExposedEntities(hass) - await exposed_entities2.async_load() + await exposed_entities2.async_initialize() assert exposed_entities._assistants == exposed_entities2._assistants - assert exposed_entities.data == exposed_entities2.data + assert exposed_entities.entities == exposed_entities2.entities async def test_expose_entity( @@ -132,7 +132,7 @@ async def test_expose_entity( entry2 = entity_registry.async_get_or_create("test", "test", "unique2") exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] - assert len(exposed_entities.data) == 0 + assert len(exposed_entities.entities) == 0 # Set options await ws_client.send_json_auto_id( @@ -151,7 +151,7 @@ async def test_expose_entity( assert entry1.options == {"cloud.alexa": {"should_expose": True}} entry2 = entity_registry.async_get(entry2.entity_id) assert entry2.options == {} - assert len(exposed_entities.data) == 0 + assert len(exposed_entities.entities) == 0 # Update options await ws_client.send_json_auto_id( @@ -176,7 +176,7 @@ async def test_expose_entity( "cloud.alexa": {"should_expose": False}, "cloud.google_assistant": {"should_expose": False}, } - assert len(exposed_entities.data) == 0 + assert len(exposed_entities.entities) == 0 async def test_expose_entity_unknown( @@ -189,7 +189,7 @@ async def test_expose_entity_unknown( await hass.async_block_till_done() exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] - assert len(exposed_entities.data) == 0 + assert len(exposed_entities.entities) == 0 # Set options await ws_client.send_json_auto_id( @@ -204,8 +204,8 @@ async def test_expose_entity_unknown( response = await ws_client.receive_json() assert response["success"] - assert len(exposed_entities.data) == 1 - assert exposed_entities.data == { + assert len(exposed_entities.entities) == 1 + assert exposed_entities.entities == { "test.test": ExposedEntity({"cloud.alexa": {"should_expose": True}}) } @@ -222,8 +222,8 @@ async def test_expose_entity_unknown( response = await ws_client.receive_json() assert response["success"] - assert len(exposed_entities.data) == 2 - assert exposed_entities.data == { + assert len(exposed_entities.entities) == 2 + assert exposed_entities.entities == { "test.test": ExposedEntity( { "cloud.alexa": {"should_expose": False}, @@ -292,7 +292,7 @@ async def test_expose_new_entities( assert response["result"] == {"expose_new": False} # Check if exposed - should be False - assert await async_should_expose(hass, "cloud.alexa", entry1.entity_id) is False + assert async_should_expose(hass, "cloud.alexa", entry1.entity_id) is False # Expose new entities to Alexa await ws_client.send_json_auto_id( @@ -315,12 +315,10 @@ async def test_expose_new_entities( assert response["result"] == {"expose_new": expose_new} # Check again if exposed - should still be False - assert await async_should_expose(hass, "cloud.alexa", entry1.entity_id) is False + assert async_should_expose(hass, "cloud.alexa", entry1.entity_id) is False # Check if exposed - should be True - assert ( - await async_should_expose(hass, "cloud.alexa", entry2.entity_id) == expose_new - ) + assert async_should_expose(hass, "cloud.alexa", entry2.entity_id) == expose_new async def test_listen_updates( @@ -342,21 +340,21 @@ async def test_listen_updates( entry = entity_registry.async_get_or_create("climate", "test", "unique1") # Call for another assistant - listener not called - await exposed_entities.async_expose_entity( + exposed_entities.async_expose_entity( "cloud.google_assistant", entry.entity_id, True ) assert len(calls) == 0 # Call for our assistant - listener called - await exposed_entities.async_expose_entity("cloud.alexa", entry.entity_id, True) + exposed_entities.async_expose_entity("cloud.alexa", entry.entity_id, True) assert len(calls) == 1 # Settings not changed - listener not called - await exposed_entities.async_expose_entity("cloud.alexa", entry.entity_id, True) + exposed_entities.async_expose_entity("cloud.alexa", entry.entity_id, True) assert len(calls) == 1 # Settings changed - listener called - await exposed_entities.async_expose_entity("cloud.alexa", entry.entity_id, False) + exposed_entities.async_expose_entity("cloud.alexa", entry.entity_id, False) assert len(calls) == 2 @@ -375,10 +373,8 @@ async def test_get_assistant_settings( assert async_get_assistant_settings(hass, "cloud.alexa") == {} - await exposed_entities.async_expose_entity("cloud.alexa", entry.entity_id, True) - await exposed_entities.async_expose_entity( - "cloud.alexa", "light.not_in_registry", True - ) + exposed_entities.async_expose_entity("cloud.alexa", entry.entity_id, True) + exposed_entities.async_expose_entity("cloud.alexa", "light.not_in_registry", True) assert async_get_assistant_settings(hass, "cloud.alexa") == snapshot assert async_get_assistant_settings(hass, "cloud.google_assistant") == snapshot @@ -412,45 +408,38 @@ async def test_should_expose( assert response["success"] # Unknown entity is not exposed - assert await async_should_expose(hass, "test.test", "test.test") is False + assert async_should_expose(hass, "test.test", "test.test") is False # Blocked entity is not exposed - assert await async_should_expose(hass, "cloud.alexa", entities["blocked"]) is False + assert async_should_expose(hass, "cloud.alexa", entities["blocked"]) is False # Lock is exposed - assert await async_should_expose(hass, "cloud.alexa", entities["lock"]) is True + assert async_should_expose(hass, "cloud.alexa", entities["lock"]) is True # Binary sensor without device class is not exposed - assert ( - await async_should_expose(hass, "cloud.alexa", entities["binary_sensor"]) - is False - ) + assert async_should_expose(hass, "cloud.alexa", entities["binary_sensor"]) is False # Binary sensor with certain device class is exposed - assert ( - await async_should_expose(hass, "cloud.alexa", entities["door_sensor"]) is True - ) + assert async_should_expose(hass, "cloud.alexa", entities["door_sensor"]) is True # Sensor without device class is not exposed - assert await async_should_expose(hass, "cloud.alexa", entities["sensor"]) is False + assert async_should_expose(hass, "cloud.alexa", entities["sensor"]) is False # Sensor with certain device class is exposed assert ( - await async_should_expose(hass, "cloud.alexa", entities["temperature_sensor"]) - is True + async_should_expose(hass, "cloud.alexa", entities["temperature_sensor"]) is True ) # The second time we check, it should load it from storage assert ( - await async_should_expose(hass, "cloud.alexa", entities["temperature_sensor"]) - is True + async_should_expose(hass, "cloud.alexa", entities["temperature_sensor"]) is True ) # Check with a different assistant exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] exposed_entities.async_set_expose_new_entities("cloud.no_default_expose", False) assert ( - await async_should_expose( + async_should_expose( hass, "cloud.no_default_expose", entities["temperature_sensor"] ) is False @@ -481,13 +470,13 @@ async def test_should_expose_hidden_categorized( entity_registry.async_get_or_create( "lock", "test", "unique2", hidden_by=er.RegistryEntryHider.USER ) - assert await async_should_expose(hass, "cloud.alexa", "lock.test_unique2") is False + assert async_should_expose(hass, "cloud.alexa", "lock.test_unique2") is False # Entity with category is not exposed entity_registry.async_get_or_create( "lock", "test", "unique3", entity_category=EntityCategory.CONFIG ) - assert await async_should_expose(hass, "cloud.alexa", "lock.test_unique3") is False + assert async_should_expose(hass, "cloud.alexa", "lock.test_unique3") is False async def test_list_exposed_entities( @@ -555,8 +544,8 @@ async def test_listeners( callbacks = [] exposed_entities.async_listen_entity_updates("test1", lambda: callbacks.append(1)) - await async_expose_entity(hass, "test1", "light.kitchen", True) + async_expose_entity(hass, "test1", "light.kitchen", True) assert len(callbacks) == 1 entry1 = entity_registry.async_get_or_create("switch", "test", "unique1") - await async_expose_entity(hass, "test1", entry1.entity_id, True) + async_expose_entity(hass, "test1", entry1.entity_id, True) diff --git a/tests/components/switch_as_x/test_init.py b/tests/components/switch_as_x/test_init.py index ad7bf732dcce..fac744d0c0ed 100644 --- a/tests/components/switch_as_x/test_init.py +++ b/tests/components/switch_as_x/test_init.py @@ -702,7 +702,7 @@ async def test_import_expose_settings_1( original_name="ABC", ) for assistant, should_expose in EXPOSE_SETTINGS.items(): - await exposed_entities.async_expose_entity( + exposed_entities.async_expose_entity( hass, assistant, switch_entity_entry.entity_id, should_expose ) @@ -760,7 +760,7 @@ async def test_import_expose_settings_2( original_name="ABC", ) for assistant, should_expose in EXPOSE_SETTINGS.items(): - await exposed_entities.async_expose_entity( + exposed_entities.async_expose_entity( hass, assistant, switch_entity_entry.entity_id, should_expose ) @@ -785,7 +785,7 @@ async def test_import_expose_settings_2( suggested_object_id="abc", ) for assistant, should_expose in EXPOSE_SETTINGS.items(): - await exposed_entities.async_expose_entity( + exposed_entities.async_expose_entity( hass, assistant, switch_as_x_entity_entry.entity_id, not should_expose ) @@ -850,7 +850,7 @@ async def test_restore_expose_settings( suggested_object_id="abc", ) for assistant, should_expose in EXPOSE_SETTINGS.items(): - await exposed_entities.async_expose_entity( + exposed_entities.async_expose_entity( hass, assistant, switch_as_x_entity_entry.entity_id, should_expose ) From 1d6b08e1945c33aa2126fe67dec54a53f9653e09 Mon Sep 17 00:00:00 2001 From: repaxan Date: Wed, 3 May 2023 05:35:20 -0700 Subject: [PATCH 142/822] Add ZHA binding for window coverings (#92387) --- homeassistant/components/zha/core/cluster_handlers/closures.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/zha/core/cluster_handlers/closures.py b/homeassistant/components/zha/core/cluster_handlers/closures.py index 4080e95748af..ab58405b9741 100644 --- a/homeassistant/components/zha/core/cluster_handlers/closures.py +++ b/homeassistant/components/zha/core/cluster_handlers/closures.py @@ -117,6 +117,7 @@ class WindowCoveringClient(ClientClusterHandler): """Window client cluster handler.""" +@registries.BINDABLE_CLUSTERS.register(closures.WindowCovering.cluster_id) @registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(closures.WindowCovering.cluster_id) class WindowCovering(ClusterHandler): """Window cluster handler.""" From 6a8668effc9ef10aa02c541481f73465085a94c7 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Wed, 3 May 2023 08:35:53 -0400 Subject: [PATCH 143/822] Bump ZHA quirks (#92400) --- 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 2063a9906ec4..9407dc841479 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -23,7 +23,7 @@ "bellows==0.35.2", "pyserial==3.5", "pyserial-asyncio==0.6", - "zha-quirks==0.0.98", + "zha-quirks==0.0.99", "zigpy-deconz==0.21.0", "zigpy==0.55.0", "zigpy-xbee==0.18.0", diff --git a/requirements_all.txt b/requirements_all.txt index 11635fe66417..7fd5aa8baaa9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2709,7 +2709,7 @@ zeroconf==0.60.0 zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.98 +zha-quirks==0.0.99 # homeassistant.components.zhong_hong zhong_hong_hvac==1.0.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 30aaf1e2b056..a6f06994602a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1964,7 +1964,7 @@ zeroconf==0.60.0 zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.98 +zha-quirks==0.0.99 # homeassistant.components.zha zigpy-deconz==0.21.0 From 0126cfa9d99af8884e62cd366c252aaea686b313 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 3 May 2023 15:45:54 +0200 Subject: [PATCH 144/822] Allow exposing any entity to the default conversation agent (#92398) * Allow exposing any entity to the default conversation agent * Tweak * Fix race, update tests * Update tests --- .../components/conversation/const.py | 1 + .../components/conversation/default_agent.py | 78 ++++++++++++------ .../homeassistant/exposed_entities.py | 6 +- homeassistant/helpers/entity_registry.py | 20 ----- .../air_quality/test_air_quality.py | 8 ++ tests/components/alexa/test_smart_home.py | 1 + tests/components/automation/test_init.py | 1 + tests/components/calendar/conftest.py | 11 +++ tests/components/calendar/test_recorder.py | 8 ++ tests/components/camera/conftest.py | 7 ++ tests/components/camera/test_recorder.py | 8 ++ .../conversation/test_default_agent.py | 23 +++--- tests/components/conversation/test_init.py | 81 ++----------------- tests/components/device_tracker/test_init.py | 1 + .../facebox/test_image_processing.py | 6 ++ .../google_assistant_sdk/test_init.py | 1 + tests/components/homekit/test_type_cameras.py | 6 ++ .../components/image_processing/test_init.py | 6 ++ tests/components/media_player/test_init.py | 6 ++ .../components/media_player/test_recorder.py | 1 + tests/components/microsoft_face/test_init.py | 6 ++ .../test_image_processing.py | 6 ++ .../test_image_processing.py | 6 ++ .../openalpr_cloud/test_image_processing.py | 6 ++ tests/components/rtsp_to_webrtc/test_init.py | 7 ++ tests/components/sensor/test_recorder.py | 1 + .../sighthound/test_image_processing.py | 6 ++ tests/components/tts/test_notify.py | 2 + .../components/universal/test_media_player.py | 2 + tests/components/weather/test_recorder.py | 1 + 30 files changed, 195 insertions(+), 128 deletions(-) create mode 100644 tests/components/calendar/conftest.py diff --git a/homeassistant/components/conversation/const.py b/homeassistant/components/conversation/const.py index 7ba12ec830db..a8828fcc0e9d 100644 --- a/homeassistant/components/conversation/const.py +++ b/homeassistant/components/conversation/const.py @@ -1,4 +1,5 @@ """Const for conversation integration.""" DOMAIN = "conversation" +DEFAULT_EXPOSED_ATTRIBUTES = {"device_class"} HOME_ASSISTANT_AGENT = "homeassistant" diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index d347140af2e2..8a5ef7b294e2 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -21,19 +21,21 @@ from homeassistant.components.homeassistant.exposed_entities import ( async_listen_entity_updates, async_should_expose, ) -from homeassistant.const import ATTR_DEVICE_CLASS +from homeassistant.const import MATCH_ALL from homeassistant.helpers import ( area_registry as ar, device_registry as dr, entity_registry as er, intent, + start, template, translation, ) +from homeassistant.helpers.event import async_track_state_change from homeassistant.util.json import JsonObjectType, json_loads_object from .agent import AbstractConversationAgent, ConversationInput, ConversationResult -from .const import DOMAIN +from .const import DEFAULT_EXPOSED_ATTRIBUTES, DOMAIN _LOGGER = logging.getLogger(__name__) _DEFAULT_ERROR_TEXT = "Sorry, I couldn't understand that" @@ -81,16 +83,24 @@ def async_setup(hass: core.HomeAssistant) -> None: async_should_expose(hass, DOMAIN, entity_id) @core.callback - def async_handle_entity_registry_changed(event: core.Event) -> None: - """Set expose flag on newly created entities.""" - if event.data["action"] == "create": - async_should_expose(hass, DOMAIN, event.data["entity_id"]) + def async_entity_state_listener( + changed_entity: str, + old_state: core.State | None, + new_state: core.State | None, + ): + """Set expose flag on new entities.""" + if old_state is not None or new_state is None: + return + async_should_expose(hass, DOMAIN, changed_entity) - hass.bus.async_listen( - er.EVENT_ENTITY_REGISTRY_UPDATED, - async_handle_entity_registry_changed, - run_immediately=True, - ) + @core.callback + def async_hass_started(hass: core.HomeAssistant) -> None: + """Set expose flag on all entities.""" + for state in hass.states.async_all(): + async_should_expose(hass, DOMAIN, state.entity_id) + async_track_state_change(hass, MATCH_ALL, async_entity_state_listener) + + start.async_at_started(hass, async_hass_started) class DefaultAgent(AbstractConversationAgent): @@ -130,6 +140,11 @@ class DefaultAgent(AbstractConversationAgent): self._async_handle_entity_registry_changed, run_immediately=True, ) + self.hass.bus.async_listen( + core.EVENT_STATE_CHANGED, + self._async_handle_state_changed, + run_immediately=True, + ) async_listen_entity_updates( self.hass, DOMAIN, self._async_exposed_entities_updated ) @@ -475,12 +490,19 @@ class DefaultAgent(AbstractConversationAgent): @core.callback def _async_handle_entity_registry_changed(self, event: core.Event) -> None: """Clear names list cache when an entity registry entry has changed.""" - if event.data["action"] == "update" and not any( + if event.data["action"] != "update" or not any( field in event.data["changes"] for field in _ENTITY_REGISTRY_UPDATE_FIELDS ): return self._slot_lists = None + @core.callback + def _async_handle_state_changed(self, event: core.Event) -> None: + """Clear names list cache when a state is added or removed from the state machine.""" + if event.data.get("old_state") and event.data.get("new_state"): + return + self._slot_lists = None + @core.callback def _async_exposed_entities_updated(self) -> None: """Handle updated preferences.""" @@ -493,30 +515,38 @@ class DefaultAgent(AbstractConversationAgent): area_ids_with_entities: set[str] = set() entity_registry = er.async_get(self.hass) - entities = [ - entity - for entity in entity_registry.entities.values() - if async_should_expose(self.hass, DOMAIN, entity.entity_id) + states = [ + state + for state in self.hass.states.async_all() + if async_should_expose(self.hass, DOMAIN, state.entity_id) ] devices = dr.async_get(self.hass) # Gather exposed entity names entity_names = [] - for entity in entities: + for state in states: # Checked against "requires_context" and "excludes_context" in hassil - context = {"domain": entity.domain} - if entity.device_class: - context[ATTR_DEVICE_CLASS] = entity.device_class + context = {"domain": state.domain} + if state.attributes: + # Include some attributes + for attr in DEFAULT_EXPOSED_ATTRIBUTES: + if attr not in state.attributes: + continue + context[attr] = state.attributes[attr] + + entity = entity_registry.async_get(state.entity_id) + + if not entity: + # Default name + entity_names.append((state.name, state.name, context)) + continue if entity.aliases: for alias in entity.aliases: entity_names.append((alias, alias, context)) # Default name - name = entity.async_friendly_name(self.hass) or entity.entity_id.replace( - "_", " " - ) - entity_names.append((name, name, context)) + entity_names.append((state.name, state.name, context)) if entity.area_id: # Expose area too diff --git a/homeassistant/components/homeassistant/exposed_entities.py b/homeassistant/components/homeassistant/exposed_entities.py index 56df611b3236..159b54cb5a87 100644 --- a/homeassistant/components/homeassistant/exposed_entities.py +++ b/homeassistant/components/homeassistant/exposed_entities.py @@ -305,7 +305,11 @@ class ExposedEntities: if domain in DEFAULT_EXPOSED_DOMAINS: return True - device_class = get_device_class(self._hass, entity_id) + try: + device_class = get_device_class(self._hass, entity_id) + except HomeAssistantError: + # The entity no longer exists + return False if ( domain == "binary_sensor" and device_class in DEFAULT_EXPOSED_BINARY_SENSOR_DEVICE_CLASSES diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 3ed0ff8d4721..d8c5a6c1cf67 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -307,26 +307,6 @@ class RegistryEntry: hass.states.async_set(self.entity_id, STATE_UNAVAILABLE, attrs) - def async_friendly_name(self, hass: HomeAssistant) -> str | None: - """Return the friendly name. - - If self.name is not None, this returns self.name - If has_entity_name is False, self.original_name - If has_entity_name is True, this returns device.name + self.original_name - """ - if not self.has_entity_name or self.name is not None: - return self.name or self.original_name - - device_registry = dr.async_get(hass) - if not (device_id := self.device_id) or not ( - device_entry := device_registry.async_get(device_id) - ): - return self.original_name - - if not (original_name := self.original_name): - return device_entry.name_by_user or device_entry.name - return f"{device_entry.name_by_user or device_entry.name} {original_name}" - class EntityRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]): """Store entity registry data.""" diff --git a/tests/components/air_quality/test_air_quality.py b/tests/components/air_quality/test_air_quality.py index c2c18a6ed096..faaebda0aae0 100644 --- a/tests/components/air_quality/test_air_quality.py +++ b/tests/components/air_quality/test_air_quality.py @@ -1,4 +1,6 @@ """The tests for the Air Quality component.""" +import pytest + from homeassistant.components.air_quality import ATTR_N2O, ATTR_OZONE, ATTR_PM_10 from homeassistant.const import ( ATTR_ATTRIBUTION, @@ -9,6 +11,12 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +@pytest.fixture(autouse=True) +async def setup_homeassistant(hass: HomeAssistant): + """Set up the homeassistant integration.""" + await async_setup_component(hass, "homeassistant", {}) + + async def test_state(hass: HomeAssistant) -> None: """Test Air Quality state.""" config = {"air_quality": {"platform": "demo"}} diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 601f59fd1187..a1f77a9b49b3 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -39,6 +39,7 @@ def events(hass: HomeAssistant) -> list[Event]: @pytest.fixture async def mock_camera(hass: HomeAssistant) -> None: """Initialize a demo camera platform.""" + assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component( hass, "camera", {camera.DOMAIN: {"platform": "demo"}} ) diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index b8a1c52f4731..3859e0c857c9 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -1539,6 +1539,7 @@ async def test_automation_restore_last_triggered_with_initial_state( async def test_extraction_functions(hass: HomeAssistant) -> None: """Test extraction functions.""" + await async_setup_component(hass, "homeassistant", {}) await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}}) assert await async_setup_component( hass, diff --git a/tests/components/calendar/conftest.py b/tests/components/calendar/conftest.py new file mode 100644 index 000000000000..4d6b5adfde70 --- /dev/null +++ b/tests/components/calendar/conftest.py @@ -0,0 +1,11 @@ +"""Test fixtures for calendar sensor platforms.""" +import pytest + +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + + +@pytest.fixture(autouse=True) +async def setup_homeassistant(hass: HomeAssistant): + """Set up the homeassistant integration.""" + await async_setup_component(hass, "homeassistant", {}) diff --git a/tests/components/calendar/test_recorder.py b/tests/components/calendar/test_recorder.py index 4adb580a83e0..d99a50bd286c 100644 --- a/tests/components/calendar/test_recorder.py +++ b/tests/components/calendar/test_recorder.py @@ -1,6 +1,8 @@ """The tests for calendar recorder.""" from datetime import timedelta +import pytest + from homeassistant.components.recorder import Recorder from homeassistant.components.recorder.history import get_significant_states from homeassistant.const import ATTR_FRIENDLY_NAME @@ -12,9 +14,15 @@ from tests.common import async_fire_time_changed from tests.components.recorder.common import async_wait_recording_done +@pytest.fixture(autouse=True) +async def setup_homeassistant(): + """Override the fixture in calendar.conftest.""" + + async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test sensor attributes to be excluded.""" now = dt_util.utcnow() + await async_setup_component(hass, "homeassistant", {}) await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}}) await hass.async_block_till_done() diff --git a/tests/components/camera/conftest.py b/tests/components/camera/conftest.py index 65145f9d3be0..8953158e4234 100644 --- a/tests/components/camera/conftest.py +++ b/tests/components/camera/conftest.py @@ -5,11 +5,18 @@ import pytest from homeassistant.components import camera from homeassistant.components.camera.const import StreamType +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from .common import WEBRTC_ANSWER +@pytest.fixture(autouse=True) +async def setup_homeassistant(hass: HomeAssistant): + """Set up the homeassistant integration.""" + await async_setup_component(hass, "homeassistant", {}) + + @pytest.fixture(name="mock_camera") async def mock_camera_fixture(hass): """Initialize a demo camera platform.""" diff --git a/tests/components/camera/test_recorder.py b/tests/components/camera/test_recorder.py index c6596e722515..df2b8cbe737c 100644 --- a/tests/components/camera/test_recorder.py +++ b/tests/components/camera/test_recorder.py @@ -3,6 +3,8 @@ from __future__ import annotations from datetime import timedelta +import pytest + from homeassistant.components import camera from homeassistant.components.recorder import Recorder from homeassistant.components.recorder.history import get_significant_states @@ -20,9 +22,15 @@ from tests.common import async_fire_time_changed from tests.components.recorder.common import async_wait_recording_done +@pytest.fixture(autouse=True) +async def setup_homeassistant(): + """Override the fixture in calendar.conftest.""" + + async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test camera registered attributes to be excluded.""" now = dt_util.utcnow() + await async_setup_component(hass, "homeassistant", {}) await async_setup_component( hass, camera.DOMAIN, {camera.DOMAIN: {"platform": "demo"}} ) diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index 44bb31119879..ced9d9cc5c8f 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -91,21 +91,21 @@ async def test_exposed_areas( ) device_registry.async_update_device(kitchen_device.id, area_id=area_kitchen.id) - kitchen_light = entity_registry.async_get_or_create( - "light", "demo", "1234", original_name="kitchen light" - ) + kitchen_light = entity_registry.async_get_or_create("light", "demo", "1234") entity_registry.async_update_entity( kitchen_light.entity_id, device_id=kitchen_device.id ) - hass.states.async_set(kitchen_light.entity_id, "on") - - bedroom_light = entity_registry.async_get_or_create( - "light", "demo", "5678", original_name="bedroom light" + hass.states.async_set( + kitchen_light.entity_id, "on", attributes={ATTR_FRIENDLY_NAME: "kitchen light"} ) + + bedroom_light = entity_registry.async_get_or_create("light", "demo", "5678") entity_registry.async_update_entity( bedroom_light.entity_id, area_id=area_bedroom.id ) - hass.states.async_set(bedroom_light.entity_id, "on") + hass.states.async_set( + bedroom_light.entity_id, "on", attributes={ATTR_FRIENDLY_NAME: "bedroom light"} + ) # Hide the bedroom light expose_entity(hass, bedroom_light.entity_id, False) @@ -156,6 +156,8 @@ async def test_expose_flag_automatically_set( assert await async_setup_component(hass, "conversation", {}) await hass.async_block_till_done() + with patch("homeassistant.components.http.start_http_server_and_save_config"): + await hass.async_start() # After setting up conversation, the expose flag should now be set on all entities assert async_get_assistant_settings(hass, conversation.DOMAIN) == { @@ -164,10 +166,11 @@ async def test_expose_flag_automatically_set( } # New entities will automatically have the expose flag set - new_light = entity_registry.async_get_or_create("light", "demo", "2345") + new_light = "light.demo_2345" + hass.states.async_set(new_light, "test") await hass.async_block_till_done() assert async_get_assistant_settings(hass, conversation.DOMAIN) == { light.entity_id: {"should_expose": True}, - new_light.entity_id: {"should_expose": True}, + new_light: {"should_expose": True}, test.entity_id: {"should_expose": False}, } diff --git a/tests/components/conversation/test_init.py b/tests/components/conversation/test_init.py index 6b981cbbdfa8..54e7020fdda5 100644 --- a/tests/components/conversation/test_init.py +++ b/tests/components/conversation/test_init.py @@ -202,11 +202,7 @@ async def test_http_processing_intent_entity_added_removed( # Add an entity entity_registry.async_get_or_create( - "light", - "demo", - "5678", - suggested_object_id="late", - original_name="friendly light", + "light", "demo", "5678", suggested_object_id="late" ) hass.states.async_set("light.late", "off", {"friendly_name": "friendly light"}) @@ -274,7 +270,7 @@ async def test_http_processing_intent_entity_added_removed( } # Now delete the entity - entity_registry.async_remove("light.late") + hass.states.async_remove("light.late") client = await hass_client() resp = await client.post( @@ -313,11 +309,7 @@ async def test_http_processing_intent_alias_added_removed( so that the new alias is available. """ entity_registry.async_get_or_create( - "light", - "demo", - "1234", - suggested_object_id="kitchen", - original_name="kitchen light", + "light", "demo", "1234", suggested_object_id="kitchen" ) hass.states.async_set("light.kitchen", "off", {"friendly_name": "kitchen light"}) @@ -438,7 +430,6 @@ async def test_http_processing_intent_entity_renamed( LIGHT_DOMAIN, {LIGHT_DOMAIN: [{"platform": "test"}]}, ) - await hass.async_block_till_done() calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") client = await hass_client() @@ -882,20 +873,9 @@ async def test_http_processing_intent_conversion_not_expose_new( @pytest.mark.parametrize("agent_id", AGENT_ID_OPTIONS) @pytest.mark.parametrize("sentence", ("turn on kitchen", "turn kitchen on")) async def test_turn_on_intent( - hass: HomeAssistant, - init_components, - entity_registry: er.EntityRegistry, - sentence, - agent_id, + hass: HomeAssistant, init_components, sentence, agent_id ) -> None: """Test calling the turn on intent.""" - entity_registry.async_get_or_create( - "light", - "demo", - "1234", - suggested_object_id="kitchen", - original_name="kitchen", - ) hass.states.async_set("light.kitchen", "off") calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") @@ -913,17 +893,8 @@ async def test_turn_on_intent( @pytest.mark.parametrize("sentence", ("turn off kitchen", "turn kitchen off")) -async def test_turn_off_intent( - hass: HomeAssistant, init_components, entity_registry: er.EntityRegistry, sentence -) -> None: +async def test_turn_off_intent(hass: HomeAssistant, init_components, sentence) -> None: """Test calling the turn on intent.""" - entity_registry.async_get_or_create( - "light", - "demo", - "1234", - suggested_object_id="kitchen", - original_name="kitchen", - ) hass.states.async_set("light.kitchen", "on") calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_off") @@ -969,21 +940,11 @@ async def test_http_api_no_match( async def test_http_api_handle_failure( - hass: HomeAssistant, - init_components, - entity_registry: er.EntityRegistry, - hass_client: ClientSessionGenerator, + hass: HomeAssistant, init_components, hass_client: ClientSessionGenerator ) -> None: """Test the HTTP conversation API with an error during handling.""" client = await hass_client() - entity_registry.async_get_or_create( - "light", - "demo", - "1234", - suggested_object_id="kitchen", - original_name="kitchen", - ) hass.states.async_set("light.kitchen", "off") # Raise an error during intent handling @@ -1020,19 +981,11 @@ async def test_http_api_handle_failure( async def test_http_api_unexpected_failure( hass: HomeAssistant, init_components, - entity_registry: er.EntityRegistry, hass_client: ClientSessionGenerator, ) -> None: """Test the HTTP conversation API with an unexpected error during handling.""" client = await hass_client() - entity_registry.async_get_or_create( - "light", - "demo", - "1234", - suggested_object_id="kitchen", - original_name="kitchen", - ) hass.states.async_set("light.kitchen", "off") # Raise an "unexpected" error during intent handling @@ -1355,17 +1308,8 @@ async def test_prepare_fail(hass: HomeAssistant) -> None: assert not agent._lang_intents.get("not-a-language") -async def test_language_region( - hass: HomeAssistant, init_components, entity_registry: er.EntityRegistry -) -> None: +async def test_language_region(hass: HomeAssistant, init_components) -> None: """Test calling the turn on intent.""" - entity_registry.async_get_or_create( - "light", - "demo", - "1234", - suggested_object_id="kitchen", - original_name="kitchen", - ) hass.states.async_set("light.kitchen", "off") calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") @@ -1414,17 +1358,8 @@ async def test_reload_on_new_component(hass: HomeAssistant) -> None: assert {"light"} == (lang_intents.loaded_components - loaded_components) -async def test_non_default_response( - hass: HomeAssistant, init_components, entity_registry: er.EntityRegistry -) -> None: +async def test_non_default_response(hass: HomeAssistant, init_components) -> None: """Test intent response that is not the default.""" - entity_registry.async_get_or_create( - "cover", - "demo", - "1234", - suggested_object_id="front_door", - original_name="front door", - ) hass.states.async_set("cover.front_door", "closed") calls = async_mock_service(hass, "cover", SERVICE_OPEN_COVER) diff --git a/tests/components/device_tracker/test_init.py b/tests/components/device_tracker/test_init.py index 555942941eb5..67bc24909c59 100644 --- a/tests/components/device_tracker/test_init.py +++ b/tests/components/device_tracker/test_init.py @@ -218,6 +218,7 @@ async def test_discover_platform( mock_demo_setup_scanner, mock_see, hass: HomeAssistant ) -> None: """Test discovery of device_tracker demo platform.""" + await async_setup_component(hass, "homeassistant", {}) with patch("homeassistant.components.device_tracker.legacy.update_config"): await discovery.async_load_platform( hass, device_tracker.DOMAIN, "demo", {"test_key": "test_val"}, {"bla": {}} diff --git a/tests/components/facebox/test_image_processing.py b/tests/components/facebox/test_image_processing.py index 25b9c5e89a24..4c6497b975bc 100644 --- a/tests/components/facebox/test_image_processing.py +++ b/tests/components/facebox/test_image_processing.py @@ -75,6 +75,12 @@ VALID_CONFIG = { } +@pytest.fixture(autouse=True) +async def setup_homeassistant(hass: HomeAssistant): + """Set up the homeassistant integration.""" + await async_setup_component(hass, "homeassistant", {}) + + @pytest.fixture def mock_healthybox(): """Mock fb.check_box_health.""" diff --git a/tests/components/google_assistant_sdk/test_init.py b/tests/components/google_assistant_sdk/test_init.py index 837f98ad4b96..884107b7eb96 100644 --- a/tests/components/google_assistant_sdk/test_init.py +++ b/tests/components/google_assistant_sdk/test_init.py @@ -209,6 +209,7 @@ async def test_send_text_command_expired_token_refresh_failure( requires_reauth: ConfigEntryState, ) -> None: """Test failure refreshing token in send_text_command.""" + await async_setup_component(hass, "homeassistant", {}) await setup_integration() entries = hass.config_entries.async_entries(DOMAIN) diff --git a/tests/components/homekit/test_type_cameras.py b/tests/components/homekit/test_type_cameras.py index 1ce876ee584c..9fcd36d06f35 100644 --- a/tests/components/homekit/test_type_cameras.py +++ b/tests/components/homekit/test_type_cameras.py @@ -43,6 +43,12 @@ MOCK_START_STREAM_SESSION_UUID = UUID("3303d503-17cc-469a-b672-92436a71a2f6") PID_THAT_WILL_NEVER_BE_ALIVE = 2147483647 +@pytest.fixture(autouse=True) +async def setup_homeassistant(hass: HomeAssistant): + """Set up the homeassistant integration.""" + await async_setup_component(hass, "homeassistant", {}) + + async def _async_start_streaming(hass, acc): """Start streaming a camera.""" acc.set_selected_stream_configuration(MOCK_START_STREAM_TLV) diff --git a/tests/components/image_processing/test_init.py b/tests/components/image_processing/test_init.py index 0c35da4d3cbd..a59761d1c74a 100644 --- a/tests/components/image_processing/test_init.py +++ b/tests/components/image_processing/test_init.py @@ -16,6 +16,12 @@ from tests.common import assert_setup_component, async_capture_events from tests.test_util.aiohttp import AiohttpClientMocker +@pytest.fixture(autouse=True) +async def setup_homeassistant(hass: HomeAssistant): + """Set up the homeassistant integration.""" + await async_setup_component(hass, "homeassistant", {}) + + @pytest.fixture def aiohttp_unused_port(event_loop, aiohttp_unused_port, socket_enabled): """Return aiohttp_unused_port and allow opening sockets.""" diff --git a/tests/components/media_player/test_init.py b/tests/components/media_player/test_init.py index eb1e52bf3353..b7bf35ab2f8b 100644 --- a/tests/components/media_player/test_init.py +++ b/tests/components/media_player/test_init.py @@ -21,6 +21,12 @@ from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator, WebSocketGenerator +@pytest.fixture(autouse=True) +async def setup_homeassistant(hass: HomeAssistant): + """Set up the homeassistant integration.""" + await async_setup_component(hass, "homeassistant", {}) + + async def test_get_image_http( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator ) -> None: diff --git a/tests/components/media_player/test_recorder.py b/tests/components/media_player/test_recorder.py index 31bf9e63dbfe..98922d7d0a42 100644 --- a/tests/components/media_player/test_recorder.py +++ b/tests/components/media_player/test_recorder.py @@ -25,6 +25,7 @@ from tests.components.recorder.common import async_wait_recording_done async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test media_player registered attributes to be excluded.""" now = dt_util.utcnow() + await async_setup_component(hass, "homeassistant", {}) await async_setup_component( hass, media_player.DOMAIN, {media_player.DOMAIN: {"platform": "demo"}} ) diff --git a/tests/components/microsoft_face/test_init.py b/tests/components/microsoft_face/test_init.py index 5f61192033c3..a33d9fcfdecf 100644 --- a/tests/components/microsoft_face/test_init.py +++ b/tests/components/microsoft_face/test_init.py @@ -25,6 +25,12 @@ from tests.common import assert_setup_component, load_fixture from tests.test_util.aiohttp import AiohttpClientMocker +@pytest.fixture(autouse=True) +async def setup_homeassistant(hass: HomeAssistant): + """Set up the homeassistant integration.""" + await async_setup_component(hass, "homeassistant", {}) + + def create_group(hass, name): """Create a new person group. diff --git a/tests/components/microsoft_face_detect/test_image_processing.py b/tests/components/microsoft_face_detect/test_image_processing.py index 835341f07573..349440124ff2 100644 --- a/tests/components/microsoft_face_detect/test_image_processing.py +++ b/tests/components/microsoft_face_detect/test_image_processing.py @@ -26,6 +26,12 @@ CONFIG = { ENDPOINT_URL = f"https://westus.{mf.FACE_API_URL}" +@pytest.fixture(autouse=True) +async def setup_homeassistant(hass: HomeAssistant): + """Set up the homeassistant integration.""" + await async_setup_component(hass, "homeassistant", {}) + + @pytest.fixture def store_mock(): """Mock update store.""" diff --git a/tests/components/microsoft_face_identify/test_image_processing.py b/tests/components/microsoft_face_identify/test_image_processing.py index 85a0ee39105e..6581aea835fe 100644 --- a/tests/components/microsoft_face_identify/test_image_processing.py +++ b/tests/components/microsoft_face_identify/test_image_processing.py @@ -14,6 +14,12 @@ from tests.components.image_processing import common from tests.test_util.aiohttp import AiohttpClientMocker +@pytest.fixture(autouse=True) +async def setup_homeassistant(hass: HomeAssistant): + """Set up the homeassistant integration.""" + await async_setup_component(hass, "homeassistant", {}) + + @pytest.fixture def store_mock(): """Mock update store.""" diff --git a/tests/components/openalpr_cloud/test_image_processing.py b/tests/components/openalpr_cloud/test_image_processing.py index 201c331a2e7e..dfda0b0d282c 100644 --- a/tests/components/openalpr_cloud/test_image_processing.py +++ b/tests/components/openalpr_cloud/test_image_processing.py @@ -14,6 +14,12 @@ from tests.components.image_processing import common from tests.test_util.aiohttp import AiohttpClientMocker +@pytest.fixture(autouse=True) +async def setup_homeassistant(hass: HomeAssistant): + """Set up the homeassistant integration.""" + await async_setup_component(hass, "homeassistant", {}) + + @pytest.fixture async def setup_openalpr_cloud(hass): """Set up openalpr cloud.""" diff --git a/tests/components/rtsp_to_webrtc/test_init.py b/tests/components/rtsp_to_webrtc/test_init.py index a6d2d34b178c..27656dd10c7f 100644 --- a/tests/components/rtsp_to_webrtc/test_init.py +++ b/tests/components/rtsp_to_webrtc/test_init.py @@ -14,6 +14,7 @@ from homeassistant.components.rtsp_to_webrtc import CONF_STUN_SERVER, DOMAIN from homeassistant.components.websocket_api.const import TYPE_RESULT from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component from .conftest import SERVER_URL, STREAM_SOURCE, ComponentSetup @@ -27,6 +28,12 @@ OFFER_SDP = "v=0\r\no=carol 28908764872 28908764872 IN IP4 100.3.6.6\r\n..." ANSWER_SDP = "v=0\r\no=bob 2890844730 2890844730 IN IP4 host.example.com\r\n..." +@pytest.fixture(autouse=True) +async def setup_homeassistant(hass: HomeAssistant): + """Set up the homeassistant integration.""" + await async_setup_component(hass, "homeassistant", {}) + + async def test_setup_success( hass: HomeAssistant, rtsp_to_webrtc_client: Any, setup_integration: ComponentSetup ) -> None: diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 241a1c60fbdb..9b297bf884f1 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -1373,6 +1373,7 @@ def test_compile_hourly_sum_statistics_negative_state( mocksensor._attr_should_poll = False platform.ENTITIES["custom_sensor"] = mocksensor + setup_component(hass, "homeassistant", {}) setup_component( hass, "sensor", {"sensor": [{"platform": "demo"}, {"platform": "test"}]} ) diff --git a/tests/components/sighthound/test_image_processing.py b/tests/components/sighthound/test_image_processing.py index 07fe2aee02c3..5961b925a2a7 100644 --- a/tests/components/sighthound/test_image_processing.py +++ b/tests/components/sighthound/test_image_processing.py @@ -46,6 +46,12 @@ MOCK_DETECTIONS = { MOCK_NOW = datetime.datetime(2020, 2, 20, 10, 5, 3) +@pytest.fixture(autouse=True) +async def setup_homeassistant(hass: HomeAssistant): + """Set up the homeassistant integration.""" + await async_setup_component(hass, "homeassistant", {}) + + @pytest.fixture def mock_detections(): """Return a mock detection.""" diff --git a/tests/components/tts/test_notify.py b/tests/components/tts/test_notify.py index 37fcbd7c7b34..be528459a705 100644 --- a/tests/components/tts/test_notify.py +++ b/tests/components/tts/test_notify.py @@ -72,6 +72,8 @@ async def test_setup_legacy_service(hass: HomeAssistant) -> None: }, } + await async_setup_component(hass, "homeassistant", {}) + with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) diff --git a/tests/components/universal/test_media_player.py b/tests/components/universal/test_media_player.py index fbf4e576dd56..a6cf342eeb39 100644 --- a/tests/components/universal/test_media_player.py +++ b/tests/components/universal/test_media_player.py @@ -1105,6 +1105,7 @@ async def test_state_template(hass: HomeAssistant) -> None: async def test_browse_media(hass: HomeAssistant) -> None: """Test browse media.""" + await async_setup_component(hass, "homeassistant", {}) await async_setup_component( hass, "media_player", {"media_player": {"platform": "demo"}} ) @@ -1135,6 +1136,7 @@ async def test_browse_media(hass: HomeAssistant) -> None: async def test_browse_media_override(hass: HomeAssistant) -> None: """Test browse media override.""" + await async_setup_component(hass, "homeassistant", {}) await async_setup_component( hass, "media_player", {"media_player": {"platform": "demo"}} ) diff --git a/tests/components/weather/test_recorder.py b/tests/components/weather/test_recorder.py index f5d031f0f6cd..5d7928124dd9 100644 --- a/tests/components/weather/test_recorder.py +++ b/tests/components/weather/test_recorder.py @@ -18,6 +18,7 @@ from tests.components.recorder.common import async_wait_recording_done async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test weather attributes to be excluded.""" now = dt_util.utcnow() + await async_setup_component(hass, "homeassistant", {}) await async_setup_component(hass, DOMAIN, {DOMAIN: {"platform": "demo"}}) hass.config.units = METRIC_SYSTEM await hass.async_block_till_done() From 9162bad4dac8cc05603bb94b217c377e98e63ff2 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 3 May 2023 15:46:14 +0200 Subject: [PATCH 145/822] Update frontend to 20230503.0 (#92402) --- 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 8167b9bbb17a..81aa690123ee 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==20230502.0"] + "requirements": ["home-assistant-frontend==20230503.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 19be55ceb266..caa3cecc72b2 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -25,7 +25,7 @@ ha-av==10.0.0 hass-nabucasa==0.66.2 hassil==1.0.6 home-assistant-bluetooth==1.10.0 -home-assistant-frontend==20230502.0 +home-assistant-frontend==20230503.0 home-assistant-intents==2023.4.26 httpx==0.24.0 ifaddr==0.1.7 diff --git a/requirements_all.txt b/requirements_all.txt index 7fd5aa8baaa9..99aa0a84f1dd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -911,7 +911,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230502.0 +home-assistant-frontend==20230503.0 # homeassistant.components.conversation home-assistant-intents==2023.4.26 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a6f06994602a..bf1cbba4e333 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -700,7 +700,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230502.0 +home-assistant-frontend==20230503.0 # homeassistant.components.conversation home-assistant-intents==2023.4.26 From 20942ab26fd5c135e3cf2cb0bbd451a5a989d724 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 3 May 2023 08:46:53 -0500 Subject: [PATCH 146/822] Handle webhook URL rejection in onvif (#92405) --- homeassistant/components/onvif/event.py | 28 ++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/onvif/event.py b/homeassistant/components/onvif/event.py index 4c2efabf61ac..851b0f26d1bc 100644 --- a/homeassistant/components/onvif/event.py +++ b/homeassistant/components/onvif/event.py @@ -11,7 +11,7 @@ from httpx import RemoteProtocolError, RequestError, TransportError from onvif import ONVIFCamera, ONVIFService from onvif.client import NotificationManager from onvif.exceptions import ONVIFError -from zeep.exceptions import Fault, XMLParseError +from zeep.exceptions import Fault, ValidationError, XMLParseError from homeassistant.components import webhook from homeassistant.config_entries import ConfigEntry @@ -35,7 +35,7 @@ from .util import stringify_onvif_error UNHANDLED_TOPICS: set[str] = {"tns1:MediaControl/VideoEncoderConfiguration"} SUBSCRIPTION_ERRORS = (Fault, asyncio.TimeoutError, TransportError) -CREATE_ERRORS = (ONVIFError, Fault, RequestError, XMLParseError) +CREATE_ERRORS = (ONVIFError, Fault, RequestError, XMLParseError, ValidationError) SET_SYNCHRONIZATION_POINT_ERRORS = (*SUBSCRIPTION_ERRORS, TypeError) UNSUBSCRIBE_ERRORS = (XMLParseError, *SUBSCRIPTION_ERRORS) RENEW_ERRORS = (ONVIFError, RequestError, XMLParseError, *SUBSCRIPTION_ERRORS) @@ -657,16 +657,34 @@ class WebHookManager: async def _async_create_webhook_subscription(self) -> None: """Create webhook subscription.""" - LOGGER.debug("%s: Creating webhook subscription", self._name) + LOGGER.debug( + "%s: Creating webhook subscription with URL: %s", + self._name, + self._webhook_url, + ) self._notification_manager = self._device.create_notification_manager( { "InitialTerminationTime": SUBSCRIPTION_RELATIVE_TIME, "ConsumerReference": {"Address": self._webhook_url}, } ) - self._webhook_subscription = await self._notification_manager.setup() + try: + self._webhook_subscription = await self._notification_manager.setup() + except ValidationError as err: + # This should only happen if there is a problem with the webhook URL + # that is causing it to not be well formed. + LOGGER.exception( + "%s: validation error while creating webhook subscription: %s", + self._name, + err, + ) + raise await self._notification_manager.start() - LOGGER.debug("%s: Webhook subscription created", self._name) + LOGGER.debug( + "%s: Webhook subscription created with URL: %s", + self._name, + self._webhook_url, + ) async def _async_start_webhook(self) -> bool: """Start webhook.""" From 31de1b17e86210535fcaca5a680753fca13fae21 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 3 May 2023 15:55:38 +0200 Subject: [PATCH 147/822] Allow setting google disable 2fa flag on any entity (#92403) * Allow setting google disable 2fa flag on any entity * Fix test * Include disable_2fa flag in cloud/google_assistant/entities/get --- .../components/cloud/google_config.py | 11 +-- homeassistant/components/cloud/http_api.py | 31 ++++----- .../homeassistant/exposed_entities.py | 67 +++++++++++-------- tests/components/cloud/test_alexa_config.py | 4 +- tests/components/cloud/test_client.py | 5 +- tests/components/cloud/test_google_config.py | 6 +- tests/components/cloud/test_http_api.py | 37 ++++++++-- tests/components/conversation/__init__.py | 4 +- .../google_assistant/test_smart_home.py | 2 +- .../homeassistant/test_exposed_entities.py | 28 ++++---- 10 files changed, 113 insertions(+), 82 deletions(-) diff --git a/homeassistant/components/cloud/google_config.py b/homeassistant/components/cloud/google_config.py index a57007891123..03dd27c7c387 100644 --- a/homeassistant/components/cloud/google_config.py +++ b/homeassistant/components/cloud/google_config.py @@ -11,6 +11,7 @@ from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.google_assistant import DOMAIN as GOOGLE_DOMAIN from homeassistant.components.google_assistant.helpers import AbstractConfig from homeassistant.components.homeassistant.exposed_entities import ( + async_get_entity_settings, async_listen_entity_updates, async_should_expose, ) @@ -23,6 +24,7 @@ from homeassistant.core import ( callback, split_entity_id, ) +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er, start from homeassistant.helpers.entity import get_device_class from homeassistant.setup import async_setup_component @@ -289,14 +291,13 @@ class CloudGoogleConfig(AbstractConfig): def should_2fa(self, state): """If an entity should be checked for 2FA.""" - entity_registry = er.async_get(self.hass) - - registry_entry = entity_registry.async_get(state.entity_id) - if not registry_entry: + try: + settings = async_get_entity_settings(self.hass, state.entity_id) + except HomeAssistantError: # Handle the entity has been removed return False - assistant_options = registry_entry.options.get(CLOUD_GOOGLE, {}) + assistant_options = settings.get(CLOUD_GOOGLE, {}) return not assistant_options.get(PREF_DISABLE_2FA, DEFAULT_DISABLE_2FA) async def async_report_state(self, message, agent_user_id: str): diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 8bc31f7b8625..391726cb9001 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -1,6 +1,7 @@ """The HTTP api to control the cloud integration.""" import asyncio from collections.abc import Mapping +from contextlib import suppress import dataclasses from functools import wraps from http import HTTPStatus @@ -21,10 +22,12 @@ from homeassistant.components.alexa import ( errors as alexa_errors, ) from homeassistant.components.google_assistant import helpers as google_helpers +from homeassistant.components.homeassistant import exposed_entities from homeassistant.components.http import HomeAssistantView from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util.location import async_detect_location_info @@ -587,10 +590,16 @@ async def google_assistant_get( ) return + assistant_options: Mapping[str, Any] = {} + with suppress(HomeAssistantError, KeyError): + settings = exposed_entities.async_get_entity_settings(hass, entity_id) + assistant_options = settings[CLOUD_GOOGLE] + result = { "entity_id": entity.entity_id, "traits": [trait.name for trait in entity.traits()], "might_2fa": entity.might_2fa_traits(), + PREF_DISABLE_2FA: assistant_options.get(PREF_DISABLE_2FA), } connection.send_result(msg["id"], result) @@ -645,27 +654,19 @@ async def google_assistant_update( msg: dict[str, Any], ) -> None: """Update google assistant entity config.""" - entity_registry = er.async_get(hass) entity_id: str = msg["entity_id"] - if not (registry_entry := entity_registry.async_get(entity_id)): - connection.send_error( - msg["id"], - websocket_api.const.ERR_NOT_ALLOWED, - f"can't configure {entity_id}", - ) - return + assistant_options: Mapping[str, Any] = {} + with suppress(HomeAssistantError, KeyError): + settings = exposed_entities.async_get_entity_settings(hass, entity_id) + assistant_options = settings[CLOUD_GOOGLE] disable_2fa = msg[PREF_DISABLE_2FA] - assistant_options: Mapping[str, Any] - if ( - assistant_options := registry_entry.options.get(CLOUD_GOOGLE, {}) - ) and assistant_options.get(PREF_DISABLE_2FA) == disable_2fa: + if assistant_options.get(PREF_DISABLE_2FA) == disable_2fa: return - assistant_options = assistant_options | {PREF_DISABLE_2FA: disable_2fa} - entity_registry.async_update_entity_options( - entity_id, CLOUD_GOOGLE, assistant_options + exposed_entities.async_set_assistant_option( + hass, CLOUD_GOOGLE, entity_id, PREF_DISABLE_2FA, disable_2fa ) connection.send_result(msg["id"]) diff --git a/homeassistant/components/homeassistant/exposed_entities.py b/homeassistant/components/homeassistant/exposed_entities.py index 159b54cb5a87..07f14e7ce8c5 100644 --- a/homeassistant/components/homeassistant/exposed_entities.py +++ b/homeassistant/components/homeassistant/exposed_entities.py @@ -132,50 +132,52 @@ class ExposedEntities: self._listeners.setdefault(assistant, []).append(listener) @callback - def async_expose_entity( - self, assistant: str, entity_id: str, should_expose: bool + def async_set_assistant_option( + self, assistant: str, entity_id: str, key: str, value: Any ) -> None: - """Expose an entity to an assistant. + """Set an option for an assistant. Notify listeners if expose flag was changed. """ entity_registry = er.async_get(self._hass) if not (registry_entry := entity_registry.async_get(entity_id)): - return self._async_expose_legacy_entity(assistant, entity_id, should_expose) + return self._async_set_legacy_assistant_option( + assistant, entity_id, key, value + ) assistant_options: Mapping[str, Any] if ( assistant_options := registry_entry.options.get(assistant, {}) - ) and assistant_options.get("should_expose") == should_expose: + ) and assistant_options.get(key) == value: return - assistant_options = assistant_options | {"should_expose": should_expose} + assistant_options = assistant_options | {key: value} entity_registry.async_update_entity_options( entity_id, assistant, assistant_options ) for listener in self._listeners.get(assistant, []): listener() - def _async_expose_legacy_entity( - self, assistant: str, entity_id: str, should_expose: bool + def _async_set_legacy_assistant_option( + self, assistant: str, entity_id: str, key: str, value: Any ) -> None: - """Expose an entity to an assistant. + """Set an option for an assistant. Notify listeners if expose flag was changed. """ if ( (exposed_entity := self.entities.get(entity_id)) and (assistant_options := exposed_entity.assistants.get(assistant, {})) - and assistant_options.get("should_expose") == should_expose + and assistant_options.get(key) == value ): return if exposed_entity: new_exposed_entity = self._update_exposed_entity( - assistant, entity_id, should_expose + assistant, entity_id, key, value ) else: - new_exposed_entity = self._new_exposed_entity(assistant, should_expose) + new_exposed_entity = self._new_exposed_entity(assistant, key, value) self.entities[entity_id] = new_exposed_entity self._async_schedule_save() for listener in self._listeners.get(assistant, []): @@ -282,10 +284,12 @@ class ExposedEntities: if exposed_entity: new_exposed_entity = self._update_exposed_entity( - assistant, entity_id, should_expose + assistant, entity_id, "should_expose", should_expose ) else: - new_exposed_entity = self._new_exposed_entity(assistant, should_expose) + new_exposed_entity = self._new_exposed_entity( + assistant, "should_expose", should_expose + ) self.entities[entity_id] = new_exposed_entity self._async_schedule_save() @@ -322,22 +326,21 @@ class ExposedEntities: return False def _update_exposed_entity( - self, - assistant: str, - entity_id: str, - should_expose: bool, + self, assistant: str, entity_id: str, key: str, value: Any ) -> ExposedEntity: """Update an exposed entity.""" entity = self.entities[entity_id] assistants = dict(entity.assistants) old_settings = assistants.get(assistant, {}) - assistants[assistant] = old_settings | {"should_expose": should_expose} + assistants[assistant] = old_settings | {key: value} return ExposedEntity(assistants) - def _new_exposed_entity(self, assistant: str, should_expose: bool) -> ExposedEntity: + def _new_exposed_entity( + self, assistant: str, key: str, value: Any + ) -> ExposedEntity: """Create a new exposed entity.""" return ExposedEntity( - assistants={assistant: {"should_expose": should_expose}}, + assistants={assistant: {key: value}}, ) async def _async_load_data(self) -> SerializedExposedEntities | None: @@ -409,12 +412,9 @@ def ws_expose_entity( ) return - exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] for entity_id in entity_ids: for assistant in msg["assistants"]: - exposed_entities.async_expose_entity( - assistant, entity_id, msg["should_expose"] - ) + async_expose_entity(hass, assistant, entity_id, msg["should_expose"]) connection.send_result(msg["id"]) @@ -513,8 +513,9 @@ def async_expose_entity( should_expose: bool, ) -> None: """Get assistant expose settings for an entity.""" - exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] - exposed_entities.async_expose_entity(assistant, entity_id, should_expose) + async_set_assistant_option( + hass, assistant, entity_id, "should_expose", should_expose + ) @callback @@ -522,3 +523,15 @@ def async_should_expose(hass: HomeAssistant, assistant: str, entity_id: str) -> """Return True if an entity should be exposed to an assistant.""" exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] return exposed_entities.async_should_expose(assistant, entity_id) + + +@callback +def async_set_assistant_option( + hass: HomeAssistant, assistant: str, entity_id: str, option: str, value: Any +) -> None: + """Set an option for an assistant. + + Notify listeners if expose flag was changed. + """ + exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] + exposed_entities.async_set_assistant_option(assistant, entity_id, option, value) diff --git a/tests/components/cloud/test_alexa_config.py b/tests/components/cloud/test_alexa_config.py index 257d04cc697b..134838ff1ce7 100644 --- a/tests/components/cloud/test_alexa_config.py +++ b/tests/components/cloud/test_alexa_config.py @@ -15,6 +15,7 @@ from homeassistant.components.cloud.prefs import CloudPreferences from homeassistant.components.homeassistant.exposed_entities import ( DATA_EXPOSED_ENTITIES, ExposedEntities, + async_expose_entity, ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant @@ -40,8 +41,7 @@ def expose_new(hass, expose_new): def expose_entity(hass, entity_id, should_expose): """Expose an entity to Alexa.""" - exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] - exposed_entities.async_expose_entity("cloud.alexa", entity_id, should_expose) + async_expose_entity(hass, "cloud.alexa", entity_id, should_expose) async def test_alexa_config_expose_entity_prefs( diff --git a/tests/components/cloud/test_client.py b/tests/components/cloud/test_client.py index d1e1a8ce112a..534456896b4c 100644 --- a/tests/components/cloud/test_client.py +++ b/tests/components/cloud/test_client.py @@ -16,6 +16,7 @@ from homeassistant.components.cloud.const import ( from homeassistant.components.homeassistant.exposed_entities import ( DATA_EXPOSED_ENTITIES, ExposedEntities, + async_expose_entity, ) from homeassistant.const import CONTENT_TYPE_JSON from homeassistant.core import HomeAssistant, State @@ -267,9 +268,7 @@ async def test_google_config_expose_entity( assert gconf.should_expose(state) - exposed_entities.async_expose_entity( - "cloud.google_assistant", entity_entry.entity_id, False - ) + async_expose_entity(hass, "cloud.google_assistant", entity_entry.entity_id, False) assert not gconf.should_expose(state) diff --git a/tests/components/cloud/test_google_config.py b/tests/components/cloud/test_google_config.py index 877a6efaf05e..c6e08f9e1528 100644 --- a/tests/components/cloud/test_google_config.py +++ b/tests/components/cloud/test_google_config.py @@ -18,6 +18,7 @@ from homeassistant.components.google_assistant import helpers as ga_helpers from homeassistant.components.homeassistant.exposed_entities import ( DATA_EXPOSED_ENTITIES, ExposedEntities, + async_expose_entity, ) from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EntityCategory from homeassistant.core import CoreState, HomeAssistant, State @@ -48,10 +49,7 @@ def expose_new(hass, expose_new): def expose_entity(hass, entity_id, should_expose): """Expose an entity to Google.""" - exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] - exposed_entities.async_expose_entity( - "cloud.google_assistant", entity_id, should_expose - ) + async_expose_entity(hass, "cloud.google_assistant", entity_id, should_expose) async def test_google_update_report_state( diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index ff4b9be4d3f6..f497c2c108ec 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -14,6 +14,7 @@ from homeassistant.components.alexa import errors as alexa_errors from homeassistant.components.alexa.entities import LightCapabilities from homeassistant.components.cloud.const import DOMAIN from homeassistant.components.google_assistant.helpers import GoogleEntity +from homeassistant.components.homeassistant import exposed_entities from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component @@ -842,6 +843,7 @@ async def test_get_google_entity( response = await client.receive_json() assert response["success"] assert response["result"] == { + "disable_2fa": None, "entity_id": "light.kitchen", "might_2fa": False, "traits": ["action.devices.traits.OnOff"], @@ -853,6 +855,30 @@ async def test_get_google_entity( response = await client.receive_json() assert response["success"] assert response["result"] == { + "disable_2fa": None, + "entity_id": "cover.garage", + "might_2fa": True, + "traits": ["action.devices.traits.OpenClose"], + } + + # Set the disable 2fa flag + await client.send_json_auto_id( + { + "type": "cloud/google_assistant/entities/update", + "entity_id": "cover.garage", + "disable_2fa": True, + } + ) + response = await client.receive_json() + assert response["success"] + + await client.send_json_auto_id( + {"type": "cloud/google_assistant/entities/get", "entity_id": "cover.garage"} + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "disable_2fa": True, "entity_id": "cover.garage", "might_2fa": True, "traits": ["action.devices.traits.OpenClose"], @@ -867,9 +893,6 @@ async def test_update_google_entity( mock_cloud_login, ) -> None: """Test that we can update config of a Google entity.""" - entry = entity_registry.async_get_or_create( - "light", "test", "unique", suggested_object_id="kitchen" - ) client = await hass_ws_client(hass) await client.send_json_auto_id( { @@ -885,16 +908,16 @@ async def test_update_google_entity( { "type": "homeassistant/expose_entity", "assistants": ["cloud.google_assistant"], - "entity_ids": [entry.entity_id], + "entity_ids": ["light.kitchen"], "should_expose": False, } ) response = await client.receive_json() assert response["success"] - assert entity_registry.async_get(entry.entity_id).options[ - "cloud.google_assistant" - ] == {"disable_2fa": False, "should_expose": False} + assert exposed_entities.async_get_entity_settings(hass, "light.kitchen") == { + "cloud.google_assistant": {"disable_2fa": False, "should_expose": False} + } async def test_list_alexa_entities( diff --git a/tests/components/conversation/__init__.py b/tests/components/conversation/__init__.py index 6eadb0680541..df57c78c9aa1 100644 --- a/tests/components/conversation/__init__.py +++ b/tests/components/conversation/__init__.py @@ -7,6 +7,7 @@ from homeassistant.components import conversation from homeassistant.components.homeassistant.exposed_entities import ( DATA_EXPOSED_ENTITIES, ExposedEntities, + async_expose_entity, ) from homeassistant.helpers import intent @@ -53,5 +54,4 @@ def expose_new(hass, expose_new): def expose_entity(hass, entity_id, should_expose): """Expose an entity to the default agent.""" - exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] - exposed_entities.async_expose_entity(conversation.DOMAIN, entity_id, should_expose) + async_expose_entity(hass, conversation.DOMAIN, entity_id, should_expose) diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index 6455128fce8b..f9ea356216fc 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -451,8 +451,8 @@ async def test_execute( hass: HomeAssistant, report_state, on, brightness, value ) -> None: """Test an execute command.""" - await async_setup_component(hass, "light", {"light": {"platform": "demo"}}) await async_setup_component(hass, "homeassistant", {}) + await async_setup_component(hass, "light", {"light": {"platform": "demo"}}) await hass.async_block_till_done() await hass.services.async_call( diff --git a/tests/components/homeassistant/test_exposed_entities.py b/tests/components/homeassistant/test_exposed_entities.py index db82a6961556..fd09bcee45a9 100644 --- a/tests/components/homeassistant/test_exposed_entities.py +++ b/tests/components/homeassistant/test_exposed_entities.py @@ -8,6 +8,7 @@ from homeassistant.components.homeassistant.exposed_entities import ( ExposedEntity, async_expose_entity, async_get_assistant_settings, + async_get_entity_settings, async_listen_entity_updates, async_should_expose, ) @@ -101,10 +102,10 @@ async def test_load_preferences(hass: HomeAssistant) -> None: exposed_entities.async_set_expose_new_entities("test1", True) exposed_entities.async_set_expose_new_entities("test2", False) - exposed_entities.async_expose_entity("test1", "light.kitchen", True) - exposed_entities.async_expose_entity("test1", "light.living_room", True) - exposed_entities.async_expose_entity("test2", "light.kitchen", True) - exposed_entities.async_expose_entity("test2", "light.kitchen", True) + async_expose_entity(hass, "test1", "light.kitchen", True) + async_expose_entity(hass, "test1", "light.living_room", True) + async_expose_entity(hass, "test2", "light.kitchen", True) + async_expose_entity(hass, "test2", "light.kitchen", True) assert list(exposed_entities._assistants) == ["test1", "test2"] assert list(exposed_entities.entities) == ["light.kitchen", "light.living_room"] @@ -334,27 +335,24 @@ async def test_listen_updates( assert await async_setup_component(hass, "homeassistant", {}) await hass.async_block_till_done() - exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] async_listen_entity_updates(hass, "cloud.alexa", listener) entry = entity_registry.async_get_or_create("climate", "test", "unique1") # Call for another assistant - listener not called - exposed_entities.async_expose_entity( - "cloud.google_assistant", entry.entity_id, True - ) + async_expose_entity(hass, "cloud.google_assistant", entry.entity_id, True) assert len(calls) == 0 # Call for our assistant - listener called - exposed_entities.async_expose_entity("cloud.alexa", entry.entity_id, True) + async_expose_entity(hass, "cloud.alexa", entry.entity_id, True) assert len(calls) == 1 # Settings not changed - listener not called - exposed_entities.async_expose_entity("cloud.alexa", entry.entity_id, True) + async_expose_entity(hass, "cloud.alexa", entry.entity_id, True) assert len(calls) == 1 # Settings changed - listener called - exposed_entities.async_expose_entity("cloud.alexa", entry.entity_id, False) + async_expose_entity(hass, "cloud.alexa", entry.entity_id, False) assert len(calls) == 2 @@ -367,19 +365,17 @@ async def test_get_assistant_settings( assert await async_setup_component(hass, "homeassistant", {}) await hass.async_block_till_done() - exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] - entry = entity_registry.async_get_or_create("climate", "test", "unique1") assert async_get_assistant_settings(hass, "cloud.alexa") == {} - exposed_entities.async_expose_entity("cloud.alexa", entry.entity_id, True) - exposed_entities.async_expose_entity("cloud.alexa", "light.not_in_registry", True) + async_expose_entity(hass, "cloud.alexa", entry.entity_id, True) + async_expose_entity(hass, "cloud.alexa", "light.not_in_registry", True) assert async_get_assistant_settings(hass, "cloud.alexa") == snapshot assert async_get_assistant_settings(hass, "cloud.google_assistant") == snapshot with pytest.raises(HomeAssistantError): - exposed_entities.async_get_entity_settings("light.unknown") + async_get_entity_settings(hass, "light.unknown") @pytest.mark.parametrize( From b71f98e8b7e3f941bb8182e89c29884d4e9910c7 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 3 May 2023 16:14:04 +0200 Subject: [PATCH 148/822] Include all entities in cloud lists (#92406) --- homeassistant/components/cloud/http_api.py | 6 ------ tests/components/cloud/test_http_api.py | 19 +++++++++++++++++-- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 391726cb9001..b0878fd24aa9 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -618,14 +618,11 @@ async def google_assistant_list( """List all google assistant entities.""" cloud = hass.data[DOMAIN] gconf = await cloud.client.get_google_config() - entity_registry = er.async_get(hass) entities = google_helpers.async_get_entities(hass, gconf) result = [] for entity in entities: - if not entity_registry.async_is_registered(entity.entity_id): - continue result.append( { "entity_id": entity.entity_id, @@ -724,14 +721,11 @@ async def alexa_list( """List all alexa entities.""" cloud = hass.data[DOMAIN] alexa_config = await cloud.client.get_alexa_config() - entity_registry = er.async_get(hass) entities = alexa_entities.async_get_entities(hass, alexa_config) result = [] for entity in entities: - if not entity_registry.async_is_registered(entity.entity_id): - continue result.append( { "entity_id": entity.entity_id, diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index f497c2c108ec..3e7523cc02c8 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -762,7 +762,17 @@ async def test_list_google_entities( await client.send_json_auto_id({"type": "cloud/google_assistant/entities"}) response = await client.receive_json() assert response["success"] - assert len(response["result"]) == 0 + assert len(response["result"]) == 2 + assert response["result"][0] == { + "entity_id": "light.kitchen", + "might_2fa": False, + "traits": ["action.devices.traits.OnOff"], + } + assert response["result"][1] == { + "entity_id": "cover.garage", + "might_2fa": True, + "traits": ["action.devices.traits.OpenClose"], + } # Add the entities to the entity registry entity_registry.async_get_or_create( @@ -939,7 +949,12 @@ async def test_list_alexa_entities( await client.send_json_auto_id({"id": 5, "type": "cloud/alexa/entities"}) response = await client.receive_json() assert response["success"] - assert len(response["result"]) == 0 + assert len(response["result"]) == 1 + assert response["result"][0] == { + "entity_id": "light.kitchen", + "display_categories": ["LIGHT"], + "interfaces": ["Alexa.PowerController", "Alexa.EndpointHealth", "Alexa"], + } # Add the entity to the entity registry entity_registry.async_get_or_create( From 470c3a0f5f4c8d21799f04d75f19921e39a4ed42 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 3 May 2023 16:50:43 +0200 Subject: [PATCH 149/822] Use exposed_entities API in cloud tests (#92408) --- tests/components/cloud/test_alexa_config.py | 75 ++++++++++---------- tests/components/cloud/test_google_config.py | 69 ++++++++---------- tests/components/cloud/test_http_api.py | 4 +- 3 files changed, 69 insertions(+), 79 deletions(-) diff --git a/tests/components/cloud/test_alexa_config.py b/tests/components/cloud/test_alexa_config.py index 134838ff1ce7..4d1f4d457df5 100644 --- a/tests/components/cloud/test_alexa_config.py +++ b/tests/components/cloud/test_alexa_config.py @@ -16,9 +16,11 @@ from homeassistant.components.homeassistant.exposed_entities import ( DATA_EXPOSED_ENTITIES, ExposedEntities, async_expose_entity, + async_get_entity_settings, ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.setup import async_setup_component @@ -602,20 +604,23 @@ async def test_alexa_config_migrate_expose_entity_prefs( ) await conf.async_initialize() - entity_exposed = entity_registry.async_get(entity_exposed.entity_id) - assert entity_exposed.options == {"cloud.alexa": {"should_expose": True}} - - entity_migrated = entity_registry.async_get(entity_migrated.entity_id) - assert entity_migrated.options == {"cloud.alexa": {"should_expose": False}} - - entity_config = entity_registry.async_get(entity_config.entity_id) - assert entity_config.options == {"cloud.alexa": {"should_expose": False}} - - entity_default = entity_registry.async_get(entity_default.entity_id) - assert entity_default.options == {"cloud.alexa": {"should_expose": True}} - - entity_blocked = entity_registry.async_get(entity_blocked.entity_id) - assert entity_blocked.options == {"cloud.alexa": {"should_expose": False}} + with pytest.raises(HomeAssistantError): + async_get_entity_settings(hass, "light.unknown") + assert async_get_entity_settings(hass, entity_migrated.entity_id) == { + "cloud.alexa": {"should_expose": False} + } + assert async_get_entity_settings(hass, entity_migrated.entity_id) == { + "cloud.alexa": {"should_expose": False} + } + assert async_get_entity_settings(hass, entity_config.entity_id) == { + "cloud.alexa": {"should_expose": False} + } + assert async_get_entity_settings(hass, entity_default.entity_id) == { + "cloud.alexa": {"should_expose": True} + } + assert async_get_entity_settings(hass, entity_blocked.entity_id) == { + "cloud.alexa": {"should_expose": False} + } async def test_alexa_config_migrate_expose_entity_prefs_default_none( @@ -646,8 +651,9 @@ async def test_alexa_config_migrate_expose_entity_prefs_default_none( ) await conf.async_initialize() - entity_default = entity_registry.async_get(entity_default.entity_id) - assert entity_default.options == {"cloud.alexa": {"should_expose": True}} + assert async_get_entity_settings(hass, entity_default.entity_id) == { + "cloud.alexa": {"should_expose": True} + } async def test_alexa_config_migrate_expose_entity_prefs_default( @@ -723,26 +729,21 @@ async def test_alexa_config_migrate_expose_entity_prefs_default( ) await conf.async_initialize() - binary_sensor_supported = entity_registry.async_get( - binary_sensor_supported.entity_id - ) - assert binary_sensor_supported.options == {"cloud.alexa": {"should_expose": True}} - - binary_sensor_unsupported = entity_registry.async_get( - binary_sensor_unsupported.entity_id - ) - assert binary_sensor_unsupported.options == { + assert async_get_entity_settings(hass, binary_sensor_supported.entity_id) == { + "cloud.alexa": {"should_expose": True} + } + assert async_get_entity_settings(hass, binary_sensor_unsupported.entity_id) == { + "cloud.alexa": {"should_expose": False} + } + assert async_get_entity_settings(hass, light.entity_id) == { + "cloud.alexa": {"should_expose": True} + } + assert async_get_entity_settings(hass, sensor_supported.entity_id) == { + "cloud.alexa": {"should_expose": True} + } + assert async_get_entity_settings(hass, sensor_unsupported.entity_id) == { + "cloud.alexa": {"should_expose": False} + } + assert async_get_entity_settings(hass, water_heater.entity_id) == { "cloud.alexa": {"should_expose": False} } - - light = entity_registry.async_get(light.entity_id) - assert light.options == {"cloud.alexa": {"should_expose": True}} - - sensor_supported = entity_registry.async_get(sensor_supported.entity_id) - assert sensor_supported.options == {"cloud.alexa": {"should_expose": True}} - - sensor_unsupported = entity_registry.async_get(sensor_unsupported.entity_id) - assert sensor_unsupported.options == {"cloud.alexa": {"should_expose": False}} - - water_heater = entity_registry.async_get(water_heater.entity_id) - assert water_heater.options == {"cloud.alexa": {"should_expose": False}} diff --git a/tests/components/cloud/test_google_config.py b/tests/components/cloud/test_google_config.py index c6e08f9e1528..0738bc6b0299 100644 --- a/tests/components/cloud/test_google_config.py +++ b/tests/components/cloud/test_google_config.py @@ -19,9 +19,11 @@ from homeassistant.components.homeassistant.exposed_entities import ( DATA_EXPOSED_ENTITIES, ExposedEntities, async_expose_entity, + async_get_entity_settings, ) from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EntityCategory from homeassistant.core import CoreState, HomeAssistant, State +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow @@ -553,27 +555,24 @@ async def test_google_config_migrate_expose_entity_prefs( ) await conf.async_initialize() - entity_exposed = entity_registry.async_get(entity_exposed.entity_id) - assert entity_exposed.options == {"cloud.google_assistant": {"should_expose": True}} - - entity_migrated = entity_registry.async_get(entity_migrated.entity_id) - assert entity_migrated.options == { + with pytest.raises(HomeAssistantError): + async_get_entity_settings(hass, "light.unknown") + assert async_get_entity_settings(hass, entity_exposed.entity_id) == { + "cloud.google_assistant": {"should_expose": True} + } + assert async_get_entity_settings(hass, entity_migrated.entity_id) == { "cloud.google_assistant": {"should_expose": False} } - - entity_no_2fa_exposed = entity_registry.async_get(entity_no_2fa_exposed.entity_id) - assert entity_no_2fa_exposed.options == { + assert async_get_entity_settings(hass, entity_no_2fa_exposed.entity_id) == { "cloud.google_assistant": {"disable_2fa": True, "should_expose": True} } - - entity_config = entity_registry.async_get(entity_config.entity_id) - assert entity_config.options == {"cloud.google_assistant": {"should_expose": False}} - - entity_default = entity_registry.async_get(entity_default.entity_id) - assert entity_default.options == {"cloud.google_assistant": {"should_expose": True}} - - entity_blocked = entity_registry.async_get(entity_blocked.entity_id) - assert entity_blocked.options == { + assert async_get_entity_settings(hass, entity_config.entity_id) == { + "cloud.google_assistant": {"should_expose": False} + } + assert async_get_entity_settings(hass, entity_default.entity_id) == { + "cloud.google_assistant": {"should_expose": True} + } + assert async_get_entity_settings(hass, entity_blocked.entity_id) == { "cloud.google_assistant": {"should_expose": False} } @@ -605,8 +604,9 @@ async def test_google_config_migrate_expose_entity_prefs_default_none( ) await conf.async_initialize() - entity_default = entity_registry.async_get(entity_default.entity_id) - assert entity_default.options == {"cloud.google_assistant": {"should_expose": True}} + assert async_get_entity_settings(hass, entity_default.entity_id) == { + "cloud.google_assistant": {"should_expose": True} + } async def test_google_config_migrate_expose_entity_prefs_default( @@ -681,32 +681,21 @@ async def test_google_config_migrate_expose_entity_prefs_default( ) await conf.async_initialize() - binary_sensor_supported = entity_registry.async_get( - binary_sensor_supported.entity_id - ) - assert binary_sensor_supported.options == { + assert async_get_entity_settings(hass, binary_sensor_supported.entity_id) == { "cloud.google_assistant": {"should_expose": True} } - - binary_sensor_unsupported = entity_registry.async_get( - binary_sensor_unsupported.entity_id - ) - assert binary_sensor_unsupported.options == { + assert async_get_entity_settings(hass, binary_sensor_unsupported.entity_id) == { "cloud.google_assistant": {"should_expose": False} } - - light = entity_registry.async_get(light.entity_id) - assert light.options == {"cloud.google_assistant": {"should_expose": True}} - - sensor_supported = entity_registry.async_get(sensor_supported.entity_id) - assert sensor_supported.options == { + assert async_get_entity_settings(hass, light.entity_id) == { "cloud.google_assistant": {"should_expose": True} } - - sensor_unsupported = entity_registry.async_get(sensor_unsupported.entity_id) - assert sensor_unsupported.options == { + assert async_get_entity_settings(hass, sensor_supported.entity_id) == { + "cloud.google_assistant": {"should_expose": True} + } + assert async_get_entity_settings(hass, sensor_unsupported.entity_id) == { + "cloud.google_assistant": {"should_expose": False} + } + assert async_get_entity_settings(hass, water_heater.entity_id) == { "cloud.google_assistant": {"should_expose": False} } - - water_heater = entity_registry.async_get(water_heater.entity_id) - assert water_heater.options == {"cloud.google_assistant": {"should_expose": False}} diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 3e7523cc02c8..8911b34055a7 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -1060,8 +1060,8 @@ async def test_update_alexa_entity( response = await client.receive_json() assert response["success"] - assert entity_registry.async_get(entry.entity_id).options["cloud.alexa"] == { - "should_expose": False + assert exposed_entities.async_get_entity_settings(hass, entry.entity_id) == { + "cloud.alexa": {"should_expose": False} } From f089f52504e40f43e42bcfec9f07f92fee7479af Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 3 May 2023 17:06:42 +0200 Subject: [PATCH 150/822] Update cloud WS API for getting entity (#92409) * Update cloud WS API for getting entity * Adjust comment --- homeassistant/components/cloud/alexa_config.py | 7 ++++++- homeassistant/components/cloud/http_api.py | 15 ++------------- tests/components/cloud/test_http_api.py | 17 +++++++++++------ 3 files changed, 19 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/cloud/alexa_config.py b/homeassistant/components/cloud/alexa_config.py index 212cbb26e0ab..6645c4f9f606 100644 --- a/homeassistant/components/cloud/alexa_config.py +++ b/homeassistant/components/cloud/alexa_config.py @@ -29,6 +29,7 @@ from homeassistant.components.homeassistant.exposed_entities import ( from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES from homeassistant.core import HomeAssistant, callback, split_entity_id +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er, start from homeassistant.helpers.entity import get_device_class from homeassistant.helpers.event import async_call_later @@ -104,7 +105,11 @@ def entity_supported(hass: HomeAssistant, entity_id: str) -> bool: if domain in SUPPORTED_DOMAINS: return True - device_class = get_device_class(hass, entity_id) + try: + device_class = get_device_class(hass, entity_id) + except HomeAssistantError: + # The entity no longer exists + return False if ( domain == "binary_sensor" and device_class in SUPPORTED_BINARY_SENSOR_DEVICE_CLASSES diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index b0878fd24aa9..f5d5c98fe1ac 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -28,7 +28,6 @@ from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util.location import async_detect_location_info @@ -569,15 +568,14 @@ async def google_assistant_get( """Get data for a single google assistant entity.""" cloud = hass.data[DOMAIN] gconf = await cloud.client.get_google_config() - entity_registry = er.async_get(hass) entity_id: str = msg["entity_id"] state = hass.states.get(entity_id) - if not entity_registry.async_is_registered(entity_id) or not state: + if not state: connection.send_error( msg["id"], websocket_api.const.ERR_NOT_FOUND, - f"{entity_id} unknown or not in the entity registry", + f"{entity_id} unknown", ) return @@ -684,17 +682,8 @@ async def alexa_get( msg: dict[str, Any], ) -> None: """Get data for a single alexa entity.""" - entity_registry = er.async_get(hass) entity_id: str = msg["entity_id"] - if not entity_registry.async_is_registered(entity_id): - connection.send_error( - msg["id"], - websocket_api.const.ERR_NOT_FOUND, - f"{entity_id} not in the entity registry", - ) - return - if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES or not entity_supported_by_alexa( hass, entity_id ): diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 8911b34055a7..ff79fd1ea77b 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -820,7 +820,7 @@ async def test_get_google_entity( assert not response["success"] assert response["error"] == { "code": "not_found", - "message": "light.kitchen unknown or not in the entity registry", + "message": "light.kitchen unknown", } # Test getting a blocked entity @@ -841,9 +841,6 @@ async def test_get_google_entity( entity_registry.async_get_or_create( "light", "test", "unique", suggested_object_id="kitchen" ) - entity_registry.async_get_or_create( - "cover", "test", "unique", suggested_object_id="garage" - ) hass.states.async_set("light.kitchen", "on") hass.states.async_set("cover.garage", "open", {"device_class": "garage"}) @@ -991,10 +988,18 @@ async def test_get_alexa_entity( {"type": "cloud/alexa/entities/get", "entity_id": "light.kitchen"} ) response = await client.receive_json() + assert response["success"] + assert response["result"] is None + + # Test getting an unknown sensor + await client.send_json_auto_id( + {"type": "cloud/alexa/entities/get", "entity_id": "sensor.temperature"} + ) + response = await client.receive_json() assert not response["success"] assert response["error"] == { - "code": "not_found", - "message": "light.kitchen not in the entity registry", + "code": "not_supported", + "message": "sensor.temperature not supported by Alexa", } # Test getting a blocked entity From 2ae3e9023878c086111558e0583dd40d4bff7315 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 3 May 2023 17:27:42 +0200 Subject: [PATCH 151/822] Update frontend to 20230503.1 (#92410) --- 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 81aa690123ee..6489b150e79d 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==20230503.0"] + "requirements": ["home-assistant-frontend==20230503.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index caa3cecc72b2..db4f54a6b047 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -25,7 +25,7 @@ ha-av==10.0.0 hass-nabucasa==0.66.2 hassil==1.0.6 home-assistant-bluetooth==1.10.0 -home-assistant-frontend==20230503.0 +home-assistant-frontend==20230503.1 home-assistant-intents==2023.4.26 httpx==0.24.0 ifaddr==0.1.7 diff --git a/requirements_all.txt b/requirements_all.txt index 99aa0a84f1dd..a07f068dabcb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -911,7 +911,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230503.0 +home-assistant-frontend==20230503.1 # homeassistant.components.conversation home-assistant-intents==2023.4.26 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bf1cbba4e333..3abcb9e6c2ce 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -700,7 +700,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230503.0 +home-assistant-frontend==20230503.1 # homeassistant.components.conversation home-assistant-intents==2023.4.26 From 74560ab1390351411ed59de71a85edf8db7d2801 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 3 May 2023 11:18:31 -0500 Subject: [PATCH 152/822] Skip unexposed entities in intent handlers (#92415) * Filter intent handler entities by exposure * Add test for skipping unexposed entities --- .../components/conversation/default_agent.py | 1 + homeassistant/components/intent/__init__.py | 4 +- homeassistant/helpers/intent.py | 24 ++++++++- .../conversation/test_default_agent.py | 49 +++++++++++++++++++ 4 files changed, 76 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 8a5ef7b294e2..dccf394ab3fa 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -201,6 +201,7 @@ class DefaultAgent(AbstractConversationAgent): user_input.text, user_input.context, language, + assistant=DOMAIN, ) except intent.IntentHandleError: _LOGGER.exception("Intent handling error") diff --git a/homeassistant/components/intent/__init__.py b/homeassistant/components/intent/__init__.py index 6bc3d88287fc..2f5ea26a8a65 100644 --- a/homeassistant/components/intent/__init__.py +++ b/homeassistant/components/intent/__init__.py @@ -140,16 +140,18 @@ class GetStateIntentHandler(intent.IntentHandler): area=area, domains=domains, device_classes=device_classes, + assistant=intent_obj.assistant, ) ) _LOGGER.debug( - "Found %s state(s) that matched: name=%s, area=%s, domains=%s, device_classes=%s", + "Found %s state(s) that matched: name=%s, area=%s, domains=%s, device_classes=%s, assistant=%s", len(states), name, area, domains, device_classes, + intent_obj.assistant, ) # Create response diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index 7a4ca862ee27..8b07c2adc9ac 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -11,6 +11,7 @@ from typing import Any, TypeVar import voluptuous as vol +from homeassistant.components.homeassistant.exposed_entities import async_should_expose from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, @@ -65,6 +66,7 @@ async def async_handle( text_input: str | None = None, context: Context | None = None, language: str | None = None, + assistant: str | None = None, ) -> IntentResponse: """Handle an intent.""" handler: IntentHandler = hass.data.get(DATA_KEY, {}).get(intent_type) @@ -79,7 +81,14 @@ async def async_handle( language = hass.config.language intent = Intent( - hass, platform, intent_type, slots or {}, text_input, context, language + hass, + platform=platform, + intent_type=intent_type, + slots=slots or {}, + text_input=text_input, + context=context, + language=language, + assistant=assistant, ) try: @@ -208,6 +217,7 @@ def async_match_states( entities: entity_registry.EntityRegistry | None = None, areas: area_registry.AreaRegistry | None = None, devices: device_registry.DeviceRegistry | None = None, + assistant: str | None = None, ) -> Iterable[State]: """Find states that match the constraints.""" if states is None: @@ -258,6 +268,14 @@ def async_match_states( states_and_entities = list(_filter_by_area(states_and_entities, area, devices)) + if assistant is not None: + # Filter by exposure + states_and_entities = [ + (state, entity) + for state, entity in states_and_entities + if async_should_expose(hass, assistant, state.entity_id) + ] + if name is not None: if devices is None: devices = device_registry.async_get(hass) @@ -387,6 +405,7 @@ class ServiceIntentHandler(IntentHandler): area=area, domains=domains, device_classes=device_classes, + assistant=intent_obj.assistant, ) ) @@ -496,6 +515,7 @@ class Intent: "context", "language", "category", + "assistant", ] def __init__( @@ -508,6 +528,7 @@ class Intent: context: Context, language: str, category: IntentCategory | None = None, + assistant: str | None = None, ) -> None: """Initialize an intent.""" self.hass = hass @@ -518,6 +539,7 @@ class Intent: self.context = context self.language = language self.category = category + self.assistant = assistant @callback def create_response(self) -> IntentResponse: diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index ced9d9cc5c8f..58fe9371e110 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -174,3 +174,52 @@ async def test_expose_flag_automatically_set( new_light: {"should_expose": True}, test.entity_id: {"should_expose": False}, } + + +async def test_unexposed_entities_skipped( + hass: HomeAssistant, + init_components, + area_registry: ar.AreaRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that unexposed entities are skipped in exposed areas.""" + area_kitchen = area_registry.async_get_or_create("kitchen") + + # Both lights are in the kitchen + exposed_light = entity_registry.async_get_or_create("light", "demo", "1234") + entity_registry.async_update_entity( + exposed_light.entity_id, + area_id=area_kitchen.id, + ) + hass.states.async_set(exposed_light.entity_id, "off") + + unexposed_light = entity_registry.async_get_or_create("light", "demo", "5678") + entity_registry.async_update_entity( + unexposed_light.entity_id, + area_id=area_kitchen.id, + ) + hass.states.async_set(unexposed_light.entity_id, "off") + + # On light is exposed, the other is not + expose_entity(hass, exposed_light.entity_id, True) + expose_entity(hass, unexposed_light.entity_id, False) + + # Only one light should be turned on + calls = async_mock_service(hass, "light", "turn_on") + result = await conversation.async_converse( + hass, "turn on kitchen lights", None, Context(), None + ) + + assert len(calls) == 1 + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + + # Only one light should be returned + hass.states.async_set(exposed_light.entity_id, "on") + hass.states.async_set(unexposed_light.entity_id, "on") + result = await conversation.async_converse( + hass, "how many lights are on in the kitchen", None, Context(), None + ) + + assert result.response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(result.response.matched_states) == 1 + assert result.response.matched_states[0].entity_id == exposed_light.entity_id From 4abf0ddeb9257b84bd4836e9d5bd96cfa85062c8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 3 May 2023 11:18:47 -0500 Subject: [PATCH 153/822] Bump bluetooth-auto-recovery to 1.1.1 (#92412) * Bump bluetooth-auto-recovery to 1.1.0 https://github.com/Bluetooth-Devices/bluetooth-auto-recovery/releases/tag/v1.1.0 In https://github.com/home-assistant/operating-system/issues/2485 is was discovered that a more aggressive reset strategy is needed due to a yet unsolved bug in the linux 6.1.x kernel series * bump to 1.1.1 since event 47 cannot be decoded (newer kernels only) --- 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 c595a0a2cb9d..84a86754ce67 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -18,7 +18,7 @@ "bleak==0.20.2", "bleak-retry-connector==3.0.2", "bluetooth-adapters==0.15.3", - "bluetooth-auto-recovery==1.0.3", + "bluetooth-auto-recovery==1.1.1", "bluetooth-data-tools==0.4.0", "dbus-fast==1.85.0" ] diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index db4f54a6b047..45e04557f79e 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -14,7 +14,7 @@ bcrypt==4.0.1 bleak-retry-connector==3.0.2 bleak==0.20.2 bluetooth-adapters==0.15.3 -bluetooth-auto-recovery==1.0.3 +bluetooth-auto-recovery==1.1.1 bluetooth-data-tools==0.4.0 certifi>=2021.5.30 ciso8601==2.3.0 diff --git a/requirements_all.txt b/requirements_all.txt index a07f068dabcb..cf014aaf6084 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -465,7 +465,7 @@ bluemaestro-ble==0.2.3 bluetooth-adapters==0.15.3 # homeassistant.components.bluetooth -bluetooth-auto-recovery==1.0.3 +bluetooth-auto-recovery==1.1.1 # homeassistant.components.bluetooth # homeassistant.components.esphome diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3abcb9e6c2ce..5e54e2c71a05 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -385,7 +385,7 @@ bluemaestro-ble==0.2.3 bluetooth-adapters==0.15.3 # homeassistant.components.bluetooth -bluetooth-auto-recovery==1.0.3 +bluetooth-auto-recovery==1.1.1 # homeassistant.components.bluetooth # homeassistant.components.esphome From 51be90d87e5105486391e07fb463a0a02949c193 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 3 May 2023 18:39:27 +0200 Subject: [PATCH 154/822] Migrate cloud settings for all Alexa entities (#92413) * Migrate cloud settings for all Alexa entities * Also set settings for unknown entities --- .../components/cloud/alexa_config.py | 53 +++++++++++++------ tests/components/cloud/test_alexa_config.py | 40 +++++++++++--- 2 files changed, 70 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/cloud/alexa_config.py b/homeassistant/components/cloud/alexa_config.py index 6645c4f9f606..bfdd2e560a55 100644 --- a/homeassistant/components/cloud/alexa_config.py +++ b/homeassistant/components/cloud/alexa_config.py @@ -22,7 +22,9 @@ from homeassistant.components.alexa import ( ) from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.homeassistant.exposed_entities import ( + async_expose_entity, async_get_assistant_settings, + async_get_entity_settings, async_listen_entity_updates, async_should_expose, ) @@ -198,35 +200,52 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): # Don't migrate if there's a YAML config return - entity_registry = er.async_get(self.hass) - - for entity_id, entry in entity_registry.entities.items(): - if CLOUD_ALEXA in entry.options: - continue - options = {"should_expose": self._should_expose_legacy(entity_id)} - entity_registry.async_update_entity_options(entity_id, CLOUD_ALEXA, options) + for state in self.hass.states.async_all(): + with suppress(HomeAssistantError): + entity_settings = async_get_entity_settings(self.hass, state.entity_id) + if CLOUD_ALEXA in entity_settings: + continue + async_expose_entity( + self.hass, + CLOUD_ALEXA, + state.entity_id, + self._should_expose_legacy(state.entity_id), + ) + for entity_id in self._prefs.alexa_entity_configs: + with suppress(HomeAssistantError): + entity_settings = async_get_entity_settings(self.hass, entity_id) + if CLOUD_ALEXA in entity_settings: + continue + async_expose_entity( + self.hass, + CLOUD_ALEXA, + entity_id, + self._should_expose_legacy(entity_id), + ) async def async_initialize(self): """Initialize the Alexa config.""" await super().async_initialize() - if self._prefs.alexa_settings_version != ALEXA_SETTINGS_VERSION: - if self._prefs.alexa_settings_version < 2: - self._migrate_alexa_entity_settings_v1() - await self._prefs.async_update( - alexa_settings_version=ALEXA_SETTINGS_VERSION + async def on_hass_started(hass): + if self._prefs.alexa_settings_version != ALEXA_SETTINGS_VERSION: + if self._prefs.alexa_settings_version < 2: + self._migrate_alexa_entity_settings_v1() + await self._prefs.async_update( + alexa_settings_version=ALEXA_SETTINGS_VERSION + ) + async_listen_entity_updates( + self.hass, CLOUD_ALEXA, self._async_exposed_entities_updated ) - async def hass_started(hass): + async def on_hass_start(hass): if self.enabled and ALEXA_DOMAIN not in self.hass.config.components: await async_setup_component(self.hass, ALEXA_DOMAIN, {}) - start.async_at_start(self.hass, hass_started) + start.async_at_start(self.hass, on_hass_start) + start.async_at_started(self.hass, on_hass_started) self._prefs.async_listen_updates(self._async_prefs_updated) - async_listen_entity_updates( - self.hass, CLOUD_ALEXA, self._async_exposed_entities_updated - ) self.hass.bus.async_listen( er.EVENT_ENTITY_REGISTRY_UPDATED, self._handle_entity_registry_updated, diff --git a/tests/components/cloud/test_alexa_config.py b/tests/components/cloud/test_alexa_config.py index 4d1f4d457df5..2a4be7e1645c 100644 --- a/tests/components/cloud/test_alexa_config.py +++ b/tests/components/cloud/test_alexa_config.py @@ -18,9 +18,12 @@ from homeassistant.components.homeassistant.exposed_entities import ( async_expose_entity, async_get_entity_settings, ) -from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.const import ( + EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STARTED, + EntityCategory, +) +from homeassistant.core import CoreState, HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.setup import async_setup_component @@ -368,6 +371,8 @@ async def test_alexa_update_expose_trigger_sync( hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, cloud_stub ) await conf.async_initialize() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() with patch_sync_helper() as (to_update, to_remove): expose_entity(hass, light_entry.entity_id, True) @@ -544,8 +549,10 @@ async def test_alexa_config_migrate_expose_entity_prefs( entity_registry: er.EntityRegistry, ) -> None: """Test migrating Alexa entity config.""" + hass.state = CoreState.starting assert await async_setup_component(hass, "homeassistant", {}) + hass.states.async_set("light.state_only", "on") entity_exposed = entity_registry.async_get_or_create( "light", "test", @@ -593,6 +600,9 @@ async def test_alexa_config_migrate_expose_entity_prefs( cloud_prefs._prefs[PREF_ALEXA_ENTITY_CONFIGS]["light.unknown"] = { PREF_SHOULD_EXPOSE: True } + cloud_prefs._prefs[PREF_ALEXA_ENTITY_CONFIGS]["light.state_only"] = { + PREF_SHOULD_EXPOSE: False + } cloud_prefs._prefs[PREF_ALEXA_ENTITY_CONFIGS][entity_exposed.entity_id] = { PREF_SHOULD_EXPOSE: True } @@ -603,12 +613,20 @@ async def test_alexa_config_migrate_expose_entity_prefs( hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, cloud_stub ) await conf.async_initialize() + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() - with pytest.raises(HomeAssistantError): - async_get_entity_settings(hass, "light.unknown") - assert async_get_entity_settings(hass, entity_migrated.entity_id) == { + assert async_get_entity_settings(hass, "light.unknown") == { + "cloud.alexa": {"should_expose": True} + } + assert async_get_entity_settings(hass, "light.state_only") == { "cloud.alexa": {"should_expose": False} } + assert async_get_entity_settings(hass, entity_exposed.entity_id) == { + "cloud.alexa": {"should_expose": True} + } assert async_get_entity_settings(hass, entity_migrated.entity_id) == { "cloud.alexa": {"should_expose": False} } @@ -630,6 +648,7 @@ async def test_alexa_config_migrate_expose_entity_prefs_default_none( entity_registry: er.EntityRegistry, ) -> None: """Test migrating Alexa entity config.""" + hass.state = CoreState.starting assert await async_setup_component(hass, "homeassistant", {}) entity_default = entity_registry.async_get_or_create( @@ -650,6 +669,10 @@ async def test_alexa_config_migrate_expose_entity_prefs_default_none( hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, cloud_stub ) await conf.async_initialize() + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() assert async_get_entity_settings(hass, entity_default.entity_id) == { "cloud.alexa": {"should_expose": True} @@ -663,6 +686,7 @@ async def test_alexa_config_migrate_expose_entity_prefs_default( entity_registry: er.EntityRegistry, ) -> None: """Test migrating Alexa entity config.""" + hass.state = CoreState.starting assert await async_setup_component(hass, "homeassistant", {}) @@ -728,6 +752,10 @@ async def test_alexa_config_migrate_expose_entity_prefs_default( hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, cloud_stub ) await conf.async_initialize() + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() assert async_get_entity_settings(hass, binary_sensor_supported.entity_id) == { "cloud.alexa": {"should_expose": True} From a9d8bc989ec37d9047be2cbffbd0697bd43eb5fc Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 3 May 2023 18:56:48 +0200 Subject: [PATCH 155/822] Migrate cloud settings for all Google entities (#92416) --- .../components/cloud/google_config.py | 73 ++++++++++++++----- tests/components/cloud/test_google_config.py | 39 ++++++++-- 2 files changed, 87 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/cloud/google_config.py b/homeassistant/components/cloud/google_config.py index 03dd27c7c387..dae1c00a33f5 100644 --- a/homeassistant/components/cloud/google_config.py +++ b/homeassistant/components/cloud/google_config.py @@ -1,5 +1,6 @@ """Google config for Cloud.""" import asyncio +from contextlib import suppress from http import HTTPStatus import logging from typing import Any @@ -11,8 +12,10 @@ from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.google_assistant import DOMAIN as GOOGLE_DOMAIN from homeassistant.components.google_assistant.helpers import AbstractConfig from homeassistant.components.homeassistant.exposed_entities import ( + async_expose_entity, async_get_entity_settings, async_listen_entity_updates, + async_set_assistant_option, async_should_expose, ) from homeassistant.components.sensor import SensorDeviceClass @@ -173,34 +176,67 @@ class CloudGoogleConfig(AbstractConfig): # Don't migrate if there's a YAML config return - entity_registry = er.async_get(self.hass) - - for entity_id, entry in entity_registry.entities.items(): - if CLOUD_GOOGLE in entry.options: - continue - options = {"should_expose": self._should_expose_legacy(entity_id)} - if _2fa_disabled := (self._2fa_disabled_legacy(entity_id) is not None): - options[PREF_DISABLE_2FA] = _2fa_disabled - entity_registry.async_update_entity_options( - entity_id, CLOUD_GOOGLE, options + for state in self.hass.states.async_all(): + entity_id = state.entity_id + with suppress(HomeAssistantError): + entity_settings = async_get_entity_settings(self.hass, entity_id) + if CLOUD_GOOGLE in entity_settings: + continue + async_expose_entity( + self.hass, + CLOUD_GOOGLE, + entity_id, + self._should_expose_legacy(entity_id), ) + if _2fa_disabled := (self._2fa_disabled_legacy(entity_id) is not None): + async_set_assistant_option( + self.hass, + CLOUD_GOOGLE, + entity_id, + PREF_DISABLE_2FA, + _2fa_disabled, + ) + for entity_id in self._prefs.google_entity_configs: + with suppress(HomeAssistantError): + entity_settings = async_get_entity_settings(self.hass, entity_id) + if CLOUD_GOOGLE in entity_settings: + continue + async_expose_entity( + self.hass, + CLOUD_GOOGLE, + entity_id, + self._should_expose_legacy(entity_id), + ) + if _2fa_disabled := (self._2fa_disabled_legacy(entity_id) is not None): + async_set_assistant_option( + self.hass, + CLOUD_GOOGLE, + entity_id, + PREF_DISABLE_2FA, + _2fa_disabled, + ) async def async_initialize(self): """Perform async initialization of config.""" await super().async_initialize() - if self._prefs.google_settings_version != GOOGLE_SETTINGS_VERSION: - if self._prefs.google_settings_version < 2: - self._migrate_google_entity_settings_v1() - await self._prefs.async_update( - google_settings_version=GOOGLE_SETTINGS_VERSION + async def on_hass_started(hass: HomeAssistant) -> None: + if self._prefs.google_settings_version != GOOGLE_SETTINGS_VERSION: + if self._prefs.google_settings_version < 2: + self._migrate_google_entity_settings_v1() + await self._prefs.async_update( + google_settings_version=GOOGLE_SETTINGS_VERSION + ) + async_listen_entity_updates( + self.hass, CLOUD_GOOGLE, self._async_exposed_entities_updated ) - async def hass_started(hass): + async def on_hass_start(hass: HomeAssistant) -> None: if self.enabled and GOOGLE_DOMAIN not in self.hass.config.components: await async_setup_component(self.hass, GOOGLE_DOMAIN, {}) - start.async_at_start(self.hass, hass_started) + start.async_at_start(self.hass, on_hass_start) + start.async_at_started(self.hass, on_hass_started) # Remove any stored user agent id that is not ours remove_agent_user_ids = [] @@ -212,9 +248,6 @@ class CloudGoogleConfig(AbstractConfig): await self.async_disconnect_agent_user(agent_user_id) self._prefs.async_listen_updates(self._async_prefs_updated) - async_listen_entity_updates( - self.hass, CLOUD_GOOGLE, self._async_exposed_entities_updated - ) self.hass.bus.async_listen( er.EVENT_ENTITY_REGISTRY_UPDATED, self._handle_entity_registry_updated, diff --git a/tests/components/cloud/test_google_config.py b/tests/components/cloud/test_google_config.py index 0738bc6b0299..0fa37ed9987f 100644 --- a/tests/components/cloud/test_google_config.py +++ b/tests/components/cloud/test_google_config.py @@ -21,9 +21,12 @@ from homeassistant.components.homeassistant.exposed_entities import ( async_expose_entity, async_get_entity_settings, ) -from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EntityCategory +from homeassistant.const import ( + EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STARTED, + EntityCategory, +) from homeassistant.core import CoreState, HomeAssistant, State -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow @@ -145,6 +148,8 @@ async def test_google_update_expose_trigger_sync( Mock(claims={"cognito:username": "abcdefghjkl"}), ) await config.async_initialize() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() await config.async_connect_agent_user("mock-user-id") with patch.object(config, "async_sync_entities") as mock_sync, patch.object( @@ -484,8 +489,10 @@ async def test_google_config_migrate_expose_entity_prefs( entity_registry: er.EntityRegistry, ) -> None: """Test migrating Google entity config.""" + hass.state = CoreState.starting assert await async_setup_component(hass, "homeassistant", {}) + hass.states.async_set("light.state_only", "on") entity_exposed = entity_registry.async_get_or_create( "light", "test", @@ -538,7 +545,11 @@ async def test_google_config_migrate_expose_entity_prefs( expose_entity(hass, entity_migrated.entity_id, False) cloud_prefs._prefs[PREF_GOOGLE_ENTITY_CONFIGS]["light.unknown"] = { - PREF_SHOULD_EXPOSE: True + PREF_SHOULD_EXPOSE: True, + PREF_DISABLE_2FA: True, + } + cloud_prefs._prefs[PREF_GOOGLE_ENTITY_CONFIGS]["light.state_only"] = { + PREF_SHOULD_EXPOSE: False } cloud_prefs._prefs[PREF_GOOGLE_ENTITY_CONFIGS][entity_exposed.entity_id] = { PREF_SHOULD_EXPOSE: True @@ -554,9 +565,17 @@ async def test_google_config_migrate_expose_entity_prefs( hass, GACTIONS_SCHEMA({}), "mock-user-id", cloud_prefs, Mock(is_logged_in=False) ) await conf.async_initialize() + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() - with pytest.raises(HomeAssistantError): - async_get_entity_settings(hass, "light.unknown") + assert async_get_entity_settings(hass, "light.unknown") == { + "cloud.google_assistant": {"disable_2fa": True, "should_expose": True} + } + assert async_get_entity_settings(hass, "light.state_only") == { + "cloud.google_assistant": {"should_expose": False} + } assert async_get_entity_settings(hass, entity_exposed.entity_id) == { "cloud.google_assistant": {"should_expose": True} } @@ -583,6 +602,7 @@ async def test_google_config_migrate_expose_entity_prefs_default_none( entity_registry: er.EntityRegistry, ) -> None: """Test migrating Google entity config.""" + hass.state = CoreState.starting assert await async_setup_component(hass, "homeassistant", {}) entity_default = entity_registry.async_get_or_create( @@ -603,6 +623,10 @@ async def test_google_config_migrate_expose_entity_prefs_default_none( hass, GACTIONS_SCHEMA({}), "mock-user-id", cloud_prefs, Mock(is_logged_in=False) ) await conf.async_initialize() + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() assert async_get_entity_settings(hass, entity_default.entity_id) == { "cloud.google_assistant": {"should_expose": True} @@ -615,6 +639,7 @@ async def test_google_config_migrate_expose_entity_prefs_default( entity_registry: er.EntityRegistry, ) -> None: """Test migrating Google entity config.""" + hass.state = CoreState.starting assert await async_setup_component(hass, "homeassistant", {}) @@ -680,6 +705,10 @@ async def test_google_config_migrate_expose_entity_prefs_default( hass, GACTIONS_SCHEMA({}), "mock-user-id", cloud_prefs, Mock(is_logged_in=False) ) await conf.async_initialize() + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() assert async_get_entity_settings(hass, binary_sensor_supported.entity_id) == { "cloud.google_assistant": {"should_expose": True} From aa78962a9a3cf0f4d8d787c44a5aec7084975977 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 3 May 2023 12:43:14 -0500 Subject: [PATCH 156/822] Pass OPUS payload ID through VoIP (#92421) --- homeassistant/components/voip/voip.py | 63 +++++++++++++++------------ tests/components/voip/test_voip.py | 23 +++++++++- 2 files changed, 56 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/voip/voip.py b/homeassistant/components/voip/voip.py index ddf40f5918ec..8b96941e00a0 100644 --- a/homeassistant/components/voip/voip.py +++ b/homeassistant/components/voip/voip.py @@ -52,7 +52,11 @@ def make_protocol( or (pipeline.tts_engine is None) ): # Play pre-recorded message instead of failing - return PreRecordMessageProtocol(hass, "problem.pcm") + return PreRecordMessageProtocol( + hass, + "problem.pcm", + opus_payload_type=call_info.opus_payload_type, + ) # Pipeline is properly configured return PipelineRtpDatagramProtocol( @@ -60,6 +64,7 @@ def make_protocol( hass.config.language, voip_device, Context(user_id=devices.config_entry.data["user"]), + opus_payload_type=call_info.opus_payload_type, ) @@ -79,7 +84,9 @@ class HassVoipDatagramProtocol(VoipDatagramProtocol): hass, devices, call_info ), invalid_protocol_factory=lambda call_info: PreRecordMessageProtocol( - hass, "not_configured.pcm" + hass, + "not_configured.pcm", + opus_payload_type=call_info.opus_payload_type, ), ) self.hass = hass @@ -109,6 +116,7 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): language: str, voip_device: VoIPDevice, context: Context, + opus_payload_type: int, pipeline_timeout: float = 30.0, audio_timeout: float = 2.0, buffered_chunks_before_speech: int = 100, @@ -119,7 +127,12 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): tts_extra_timeout: float = 1.0, ) -> None: """Set up pipeline RTP server.""" - super().__init__(rate=RATE, width=WIDTH, channels=CHANNELS) + super().__init__( + rate=RATE, + width=WIDTH, + channels=CHANNELS, + opus_payload_type=opus_payload_type, + ) self.hass = hass self.language = language @@ -350,9 +363,7 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): async with async_timeout.timeout(tts_seconds + self.tts_extra_timeout): # Assume TTS audio is 16Khz 16-bit mono - await self.hass.async_add_executor_job( - partial(self.send_audio, audio_bytes, **RTP_AUDIO_SETTINGS) - ) + await self._async_send_audio(audio_bytes) except asyncio.TimeoutError as err: _LOGGER.warning("TTS timeout") raise err @@ -360,6 +371,12 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): # Signal pipeline to restart self._tts_done.set() + async def _async_send_audio(self, audio_bytes: bytes, **kwargs): + """Send audio in executor.""" + await self.hass.async_add_executor_job( + partial(self.send_audio, audio_bytes, **RTP_AUDIO_SETTINGS, **kwargs) + ) + async def _play_listening_tone(self) -> None: """Play a tone to indicate that Home Assistant is listening.""" if self._tone_bytes is None: @@ -369,13 +386,9 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): "tone.pcm", ) - await self.hass.async_add_executor_job( - partial( - self.send_audio, - self._tone_bytes, - silence_before=self.tone_delay, - **RTP_AUDIO_SETTINGS, - ) + await self._async_send_audio( + self._tone_bytes, + silence_before=self.tone_delay, ) async def _play_processing_tone(self) -> None: @@ -387,13 +400,7 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): "processing.pcm", ) - await self.hass.async_add_executor_job( - partial( - self.send_audio, - self._processing_bytes, - **RTP_AUDIO_SETTINGS, - ) - ) + await self._async_send_audio(self._processing_bytes) async def _play_error_tone(self) -> None: """Play a tone to indicate a pipeline error occurred.""" @@ -404,13 +411,7 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): "error.pcm", ) - await self.hass.async_add_executor_job( - partial( - self.send_audio, - self._error_bytes, - **RTP_AUDIO_SETTINGS, - ) - ) + await self._async_send_audio(self._error_bytes) def _load_pcm(self, file_name: str) -> bytes: """Load raw audio (16Khz, 16-bit mono).""" @@ -424,11 +425,17 @@ class PreRecordMessageProtocol(RtpDatagramProtocol): self, hass: HomeAssistant, file_name: str, + opus_payload_type: int, message_delay: float = 1.0, loop_delay: float = 2.0, ) -> None: """Set up RTP server.""" - super().__init__(rate=RATE, width=WIDTH, channels=CHANNELS) + super().__init__( + rate=RATE, + width=WIDTH, + channels=CHANNELS, + opus_payload_type=opus_payload_type, + ) self.hass = hass self.file_name = file_name self.message_delay = message_delay diff --git a/tests/components/voip/test_voip.py b/tests/components/voip/test_voip.py index aec9122fae19..bd9a3587a9a3 100644 --- a/tests/components/voip/test_voip.py +++ b/tests/components/voip/test_voip.py @@ -4,6 +4,7 @@ import time from unittest.mock import AsyncMock, Mock, patch import async_timeout +import pytest from homeassistant.components import assist_pipeline, voip from homeassistant.components.voip.devices import VoIPDevice @@ -88,6 +89,7 @@ async def test_pipeline( hass.config.language, voip_device, Context(), + opus_payload_type=123, listening_tone_enabled=False, processing_tone_enabled=False, error_tone_enabled=False, @@ -138,6 +140,7 @@ async def test_pipeline_timeout(hass: HomeAssistant, voip_device: VoIPDevice) -> hass.config.language, voip_device, Context(), + opus_payload_type=123, pipeline_timeout=0.001, listening_tone_enabled=False, processing_tone_enabled=False, @@ -178,6 +181,7 @@ async def test_stt_stream_timeout(hass: HomeAssistant, voip_device: VoIPDevice) hass.config.language, voip_device, Context(), + opus_payload_type=123, audio_timeout=0.001, listening_tone_enabled=False, processing_tone_enabled=False, @@ -247,6 +251,14 @@ async def test_tts_timeout( # Block here to force a timeout in _send_tts time.sleep(2) + async def async_send_audio(audio_bytes, **kwargs): + if audio_bytes == tone_bytes: + # Not TTS + return + + # Block here to force a timeout in _send_tts + await asyncio.sleep(2) + async def async_get_media_source_audio( hass: HomeAssistant, media_source_id: str, @@ -269,6 +281,8 @@ async def test_tts_timeout( hass.config.language, voip_device, Context(), + opus_payload_type=123, + tts_extra_timeout=0.001, listening_tone_enabled=True, processing_tone_enabled=True, error_tone_enabled=True, @@ -277,13 +291,18 @@ async def test_tts_timeout( rtp_protocol._processing_bytes = tone_bytes rtp_protocol._error_bytes = tone_bytes rtp_protocol.transport = Mock() - rtp_protocol.send_audio = Mock(side_effect=send_audio) + rtp_protocol.send_audio = Mock() + + original_send_tts = rtp_protocol._send_tts async def send_tts(*args, **kwargs): # Call original then end test successfully - rtp_protocol._send_tts(*args, **kwargs) + with pytest.raises(asyncio.TimeoutError): + await original_send_tts(*args, **kwargs) + done.set() + rtp_protocol._async_send_audio = AsyncMock(side_effect=async_send_audio) rtp_protocol._send_tts = AsyncMock(side_effect=send_tts) # silence From ef9bcd9285980385eede726e15aa1e7d20238eee Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 3 May 2023 16:09:35 -0500 Subject: [PATCH 157/822] Bump zeroconf to 0.61.0 (#92424) changelog: https://github.com/python-zeroconf/python-zeroconf/compare/0.60.0...0.61.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 e5001c17e808..b7c43e62d649 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.60.0"] + "requirements": ["zeroconf==0.61.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 45e04557f79e..bbc43a8d9348 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -52,7 +52,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtcvad==2.0.10 yarl==1.9.2 -zeroconf==0.60.0 +zeroconf==0.61.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 cf014aaf6084..78270f38975d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2703,7 +2703,7 @@ zamg==0.2.2 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.60.0 +zeroconf==0.61.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5e54e2c71a05..7a13a4ae6314 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1958,7 +1958,7 @@ youless-api==1.0.1 zamg==0.2.2 # homeassistant.components.zeroconf -zeroconf==0.60.0 +zeroconf==0.61.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From 1cca6f7c9e377a1efc86f8eab9e1458f9a0a79af Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 4 May 2023 10:34:15 +0200 Subject: [PATCH 158/822] Fix lingering timer in shelly (#92466) --- homeassistant/components/shelly/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index 597ff953656a..85207ee4475d 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -97,7 +97,7 @@ class ShellyCoordinatorBase(DataUpdateCoordinator[None], Generic[_DeviceT]): immediate=False, function=self._async_reload_entry, ) - entry.async_on_unload(self._debounced_reload.async_cancel) + entry.async_on_unload(self._debounced_reload.async_shutdown) @property def model(self) -> str: From 3b4828d736967bcb761182a276dafab189d90a29 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 4 May 2023 11:24:29 +0200 Subject: [PATCH 159/822] Drop tmpdir from media_source tests (#92478) --- tests/components/media_source/test_local_source.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/components/media_source/test_local_source.py b/tests/components/media_source/test_local_source.py index cf50e9675582..bc637caab808 100644 --- a/tests/components/media_source/test_local_source.py +++ b/tests/components/media_source/test_local_source.py @@ -1,4 +1,5 @@ """Test Local Media Source.""" +from collections.abc import AsyncGenerator from http import HTTPStatus import io from pathlib import Path @@ -18,7 +19,7 @@ from tests.typing import ClientSessionGenerator, WebSocketGenerator @pytest.fixture -async def temp_dir(hass): +async def temp_dir(hass: HomeAssistant) -> AsyncGenerator[str, None]: """Return a temp dir.""" with TemporaryDirectory() as tmpdirname: target_dir = Path(tmpdirname) / "another_subdir" @@ -131,13 +132,13 @@ async def test_media_view( async def test_upload_view( hass: HomeAssistant, hass_client: ClientSessionGenerator, - temp_dir, - tmpdir, + temp_dir: str, + tmp_path: Path, hass_admin_user: MockUser, ) -> None: """Allow uploading media.""" # We need a temp dir that's not under tempdir fixture - extra_media_dir = tmpdir + extra_media_dir = tmp_path hass.config.media_dirs["another_path"] = temp_dir img = (Path(__file__).parent.parent / "image_upload/logo.png").read_bytes() @@ -246,7 +247,7 @@ async def test_upload_view( async def test_remove_file( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - temp_dir, + temp_dir: str, hass_admin_user: MockUser, ) -> None: """Allow uploading media.""" From 6836e15d987942519f2251878056072ccf545eef Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 4 May 2023 11:25:35 +0200 Subject: [PATCH 160/822] Add type hints to tests (#92477) --- tests/components/blebox/test_cover.py | 4 ++-- .../devolo_home_control/test_switch.py | 4 ++-- tests/components/ezviz/test_config_flow.py | 16 ++++++++++------ tests/components/glances/test_sensor.py | 2 +- tests/components/lidarr/test_sensor.py | 6 ++---- tests/components/rapt_ble/test_sensor.py | 2 +- .../rituals_perfume_genie/test_init.py | 2 +- tests/components/sonos/test_repairs.py | 2 +- tests/components/voip/test_devices.py | 9 ++++----- tests/components/webhook/test_init.py | 4 ++-- tests/components/webhook/test_trigger.py | 4 +++- tests/helpers/test_template.py | 2 +- 12 files changed, 30 insertions(+), 27 deletions(-) diff --git a/tests/components/blebox/test_cover.py b/tests/components/blebox/test_cover.py index d0a10cb5bded..cbf8f5e589bc 100644 --- a/tests/components/blebox/test_cover.py +++ b/tests/components/blebox/test_cover.py @@ -427,7 +427,7 @@ async def test_closed_state(feature, hass: HomeAssistant) -> None: assert hass.states.get(entity_id).state == STATE_CLOSED -async def test_tilt_position(shutterbox, hass): +async def test_tilt_position(shutterbox, hass: HomeAssistant) -> None: """Test tilt capability is available.""" feature_mock, entity_id = shutterbox @@ -443,7 +443,7 @@ async def test_tilt_position(shutterbox, hass): assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 10 -async def test_set_tilt_position(shutterbox, hass): +async def test_set_tilt_position(shutterbox, hass: HomeAssistant) -> None: """Test tilt position setting.""" feature_mock, entity_id = shutterbox diff --git a/tests/components/devolo_home_control/test_switch.py b/tests/components/devolo_home_control/test_switch.py index 62de9038483e..8f1607024086 100644 --- a/tests/components/devolo_home_control/test_switch.py +++ b/tests/components/devolo_home_control/test_switch.py @@ -16,7 +16,7 @@ from . import configure_integration from .mocks import HomeControlMock, HomeControlMockSwitch -async def test_switch(hass: HomeAssistant): +async def test_switch(hass: HomeAssistant) -> None: """Test setup and state change of a switch device.""" entry = configure_integration(hass) test_gateway = HomeControlMockSwitch() @@ -66,7 +66,7 @@ async def test_switch(hass: HomeAssistant): assert hass.states.get(f"{DOMAIN}.test").state == STATE_UNAVAILABLE -async def test_remove_from_hass(hass: HomeAssistant): +async def test_remove_from_hass(hass: HomeAssistant) -> None: """Test removing entity.""" entry = configure_integration(hass) test_gateway = HomeControlMockSwitch() diff --git a/tests/components/ezviz/test_config_flow.py b/tests/components/ezviz/test_config_flow.py index 939bb92bcc0f..d3bd2a1bed62 100644 --- a/tests/components/ezviz/test_config_flow.py +++ b/tests/components/ezviz/test_config_flow.py @@ -106,7 +106,7 @@ async def test_user_custom_url(hass: HomeAssistant, ezviz_config_flow) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_async_step_reauth(hass, ezviz_config_flow): +async def test_async_step_reauth(hass: HomeAssistant, ezviz_config_flow) -> None: """Test the reauth step.""" result = await hass.config_entries.flow.async_init( @@ -149,7 +149,9 @@ async def test_async_step_reauth(hass, ezviz_config_flow): assert result["reason"] == "reauth_successful" -async def test_step_discovery_abort_if_cloud_account_missing(hass): +async def test_step_discovery_abort_if_cloud_account_missing( + hass: HomeAssistant, +) -> None: """Test discovery and confirm step, abort if cloud account was removed.""" result = await hass.config_entries.flow.async_init( @@ -172,7 +174,7 @@ async def test_step_discovery_abort_if_cloud_account_missing(hass): assert result["reason"] == "ezviz_cloud_account_missing" -async def test_step_reauth_abort_if_cloud_account_missing(hass): +async def test_step_reauth_abort_if_cloud_account_missing(hass: HomeAssistant) -> None: """Test reauth and confirm step, abort if cloud account was removed.""" result = await hass.config_entries.flow.async_init( @@ -183,8 +185,8 @@ async def test_step_reauth_abort_if_cloud_account_missing(hass): async def test_async_step_integration_discovery( - hass, ezviz_config_flow, ezviz_test_rtsp_config_flow -): + hass: HomeAssistant, ezviz_config_flow, ezviz_test_rtsp_config_flow +) -> None: """Test discovery and confirm step.""" with patch("homeassistant.components.ezviz.PLATFORMS_BY_TYPE", []): await init_integration(hass) @@ -530,7 +532,9 @@ async def test_user_custom_url_exception( assert result["reason"] == "unknown" -async def test_async_step_reauth_exception(hass, ezviz_config_flow): +async def test_async_step_reauth_exception( + hass: HomeAssistant, ezviz_config_flow +) -> None: """Test the reauth step exceptions.""" result = await hass.config_entries.flow.async_init( diff --git a/tests/components/glances/test_sensor.py b/tests/components/glances/test_sensor.py index e5aadc92156b..9164dbadb146 100644 --- a/tests/components/glances/test_sensor.py +++ b/tests/components/glances/test_sensor.py @@ -43,7 +43,7 @@ async def test_sensor_states(hass: HomeAssistant) -> None: ) async def test_migrate_unique_id( hass: HomeAssistant, object_id: str, old_unique_id: str, new_unique_id: str -): +) -> None: """Test unique id migration.""" old_config_data = {**MOCK_USER_INPUT, "name": "Glances"} entry = MockConfigEntry(domain=DOMAIN, data=old_config_data) diff --git a/tests/components/lidarr/test_sensor.py b/tests/components/lidarr/test_sensor.py index 7fe347f46192..7dec62f1c47c 100644 --- a/tests/components/lidarr/test_sensor.py +++ b/tests/components/lidarr/test_sensor.py @@ -1,6 +1,4 @@ """The tests for Lidarr sensor platform.""" -from unittest.mock import AsyncMock - from homeassistant.components.sensor import CONF_STATE_CLASS, SensorStateClass from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT from homeassistant.core import HomeAssistant @@ -11,9 +9,9 @@ from .conftest import ComponentSetup async def test_sensors( hass: HomeAssistant, setup_integration: ComponentSetup, - entity_registry_enabled_by_default: AsyncMock, + entity_registry_enabled_by_default: None, connection, -): +) -> None: """Test for successfully setting up the Lidarr platform.""" await setup_integration() diff --git a/tests/components/rapt_ble/test_sensor.py b/tests/components/rapt_ble/test_sensor.py index c610cc526b80..8aad759eb769 100644 --- a/tests/components/rapt_ble/test_sensor.py +++ b/tests/components/rapt_ble/test_sensor.py @@ -18,7 +18,7 @@ from tests.common import MockConfigEntry from tests.components.bluetooth import inject_bluetooth_service_info -async def test_sensors(hass: HomeAssistant): +async def test_sensors(hass: HomeAssistant) -> None: """Test setting up creates the sensors.""" entry = MockConfigEntry( domain=DOMAIN, diff --git a/tests/components/rituals_perfume_genie/test_init.py b/tests/components/rituals_perfume_genie/test_init.py index 8766e99f5422..7f2f06b707c3 100644 --- a/tests/components/rituals_perfume_genie/test_init.py +++ b/tests/components/rituals_perfume_genie/test_init.py @@ -40,7 +40,7 @@ async def test_config_entry_unload(hass: HomeAssistant) -> None: async def test_entity_id_migration( - hass: HomeAssistant, entity_registry: er.RegistryEntry + hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test the migration of unique IDs on config entry setup.""" config_entry = mock_config_entry(unique_id="binary_sensor_test_diffuser_v1") diff --git a/tests/components/sonos/test_repairs.py b/tests/components/sonos/test_repairs.py index b99704559ec2..b86c6bd5f661 100644 --- a/tests/components/sonos/test_repairs.py +++ b/tests/components/sonos/test_repairs.py @@ -17,7 +17,7 @@ from tests.common import MockConfigEntry, async_fire_time_changed async def test_subscription_repair_issues( hass: HomeAssistant, config_entry: MockConfigEntry, soco, zgs_discovery -): +) -> None: """Test repair issues handling for failed subscriptions.""" issue_registry = async_get_issue_registry(hass) diff --git a/tests/components/voip/test_devices.py b/tests/components/voip/test_devices.py index af5b176281e4..c421a08ccf89 100644 --- a/tests/components/voip/test_devices.py +++ b/tests/components/voip/test_devices.py @@ -1,5 +1,4 @@ """Test VoIP devices.""" - from __future__ import annotations from voip_utils import CallInfo @@ -7,14 +6,14 @@ from voip_utils import CallInfo from homeassistant.components.voip import DOMAIN from homeassistant.components.voip.devices import VoIPDevice, VoIPDevices from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceRegistry +from homeassistant.helpers import device_registry as dr async def test_device_registry_info( hass: HomeAssistant, voip_devices: VoIPDevices, call_info: CallInfo, - device_registry: DeviceRegistry, + device_registry: dr.DeviceRegistry, ) -> None: """Test info in device registry.""" voip_device = voip_devices.async_get_or_create(call_info) @@ -41,7 +40,7 @@ async def test_device_registry_info_from_unknown_phone( hass: HomeAssistant, voip_devices: VoIPDevices, call_info: CallInfo, - device_registry: DeviceRegistry, + device_registry: dr.DeviceRegistry, ) -> None: """Test info in device registry from unknown phone.""" call_info.headers["user-agent"] = "Unknown" @@ -58,7 +57,7 @@ async def test_remove_device_registry_entry( hass: HomeAssistant, voip_device: VoIPDevice, voip_devices: VoIPDevices, - device_registry: DeviceRegistry, + device_registry: dr.DeviceRegistry, ) -> None: """Test removing a device registry entry.""" assert voip_device.voip_id in voip_devices.devices diff --git a/tests/components/webhook/test_init.py b/tests/components/webhook/test_init.py index c0853aa49d98..ff0346a3d8b8 100644 --- a/tests/components/webhook/test_init.py +++ b/tests/components/webhook/test_init.py @@ -161,7 +161,7 @@ async def test_webhook_head(hass: HomeAssistant, mock_client) -> None: assert len(hooks) == 1 # Should not have been called -async def test_webhook_get(hass, mock_client): +async def test_webhook_get(hass: HomeAssistant, mock_client) -> None: """Test sending a get request to a webhook.""" hooks = [] webhook_id = webhook.async_generate_id() @@ -191,7 +191,7 @@ async def test_webhook_get(hass, mock_client): assert len(hooks) == 1 # Should not have been called -async def test_webhook_not_allowed_method(hass): +async def test_webhook_not_allowed_method(hass: HomeAssistant) -> None: """Test that an exception is raised if an unsupported method is used.""" webhook_id = webhook.async_generate_id() diff --git a/tests/components/webhook/test_trigger.py b/tests/components/webhook/test_trigger.py index c2788deca303..a10b77eb105b 100644 --- a/tests/components/webhook/test_trigger.py +++ b/tests/components/webhook/test_trigger.py @@ -111,7 +111,9 @@ async def test_webhook_post( assert len(events) == 1 -async def test_webhook_allowed_methods_internet(hass, hass_client_no_auth): +async def test_webhook_allowed_methods_internet( + hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator +) -> None: """Test the webhook obeys allowed_methods and local_only options.""" events = [] diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index e18758658d6d..738541473723 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -1641,7 +1641,7 @@ def test_states_function(hass: HomeAssistant) -> None: assert tpl.async_render() == "available" -def test_has_value(hass): +def test_has_value(hass: HomeAssistant) -> None: """Test has_value method.""" hass.states.async_set("test.value1", 1) hass.states.async_set("test.unavailable", STATE_UNAVAILABLE) From 8ab8b7152a6d50e0b74b16c61a14a75fdaa09ee6 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 4 May 2023 11:27:17 +0200 Subject: [PATCH 161/822] Fix lingering timer in kostal_plenticore (#92473) --- homeassistant/components/kostal_plenticore/helper.py | 10 +++++----- homeassistant/components/kostal_plenticore/number.py | 4 +++- homeassistant/components/kostal_plenticore/select.py | 6 +++++- homeassistant/components/kostal_plenticore/sensor.py | 4 +++- homeassistant/components/kostal_plenticore/switch.py | 4 +++- 5 files changed, 19 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/kostal_plenticore/helper.py b/homeassistant/components/kostal_plenticore/helper.py index cb43486dbe01..a91fb24aad7e 100644 --- a/homeassistant/components/kostal_plenticore/helper.py +++ b/homeassistant/components/kostal_plenticore/helper.py @@ -12,7 +12,7 @@ from aiohttp.client_exceptions import ClientError from pykoplenti import ApiClient, ApiException, AuthenticationException from homeassistant.const import CONF_HOST, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import HomeAssistant +from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity import DeviceInfo @@ -171,7 +171,7 @@ class PlenticoreUpdateCoordinator(DataUpdateCoordinator[_DataT]): self._fetch: dict[str, list[str]] = defaultdict(list) self._plenticore = plenticore - def start_fetch_data(self, module_id: str, data_id: str) -> None: + def start_fetch_data(self, module_id: str, data_id: str) -> CALLBACK_TYPE: """Start fetching the given data (module-id and data-id).""" self._fetch[module_id].append(data_id) @@ -180,7 +180,7 @@ class PlenticoreUpdateCoordinator(DataUpdateCoordinator[_DataT]): async def force_refresh(event_time: datetime) -> None: await self.async_request_refresh() - async_call_later(self.hass, 2, force_refresh) + return async_call_later(self.hass, 2, force_refresh) def stop_fetch_data(self, module_id: str, data_id: str) -> None: """Stop fetching the given data (module-id and data-id).""" @@ -251,7 +251,7 @@ class PlenticoreSelectUpdateCoordinator(DataUpdateCoordinator[_DataT]): def start_fetch_data( self, module_id: str, data_id: str, all_options: list[str] - ) -> None: + ) -> CALLBACK_TYPE: """Start fetching the given data (module-id and entry-id).""" self._fetch[module_id].append(data_id) self._fetch[module_id].append(all_options) @@ -261,7 +261,7 @@ class PlenticoreSelectUpdateCoordinator(DataUpdateCoordinator[_DataT]): async def force_refresh(event_time: datetime) -> None: await self.async_request_refresh() - async_call_later(self.hass, 2, force_refresh) + return async_call_later(self.hass, 2, force_refresh) def stop_fetch_data( self, module_id: str, data_id: str, all_options: list[str] diff --git a/homeassistant/components/kostal_plenticore/number.py b/homeassistant/components/kostal_plenticore/number.py index 6ea3526e360d..885b19faf28f 100644 --- a/homeassistant/components/kostal_plenticore/number.py +++ b/homeassistant/components/kostal_plenticore/number.py @@ -188,7 +188,9 @@ class PlenticoreDataNumber( async def async_added_to_hass(self) -> None: """Register this entity on the Update Coordinator.""" await super().async_added_to_hass() - self.coordinator.start_fetch_data(self.module_id, self.data_id) + self.async_on_remove( + self.coordinator.start_fetch_data(self.module_id, self.data_id) + ) async def async_will_remove_from_hass(self) -> None: """Unregister this entity from the Update Coordinator.""" diff --git a/homeassistant/components/kostal_plenticore/select.py b/homeassistant/components/kostal_plenticore/select.py index 6a7c0b35fdfa..2118d4b47c6c 100644 --- a/homeassistant/components/kostal_plenticore/select.py +++ b/homeassistant/components/kostal_plenticore/select.py @@ -127,7 +127,11 @@ class PlenticoreDataSelect( async def async_added_to_hass(self) -> None: """Register this entity on the Update Coordinator.""" await super().async_added_to_hass() - self.coordinator.start_fetch_data(self.module_id, self.data_id, self.options) + self.async_on_remove( + self.coordinator.start_fetch_data( + self.module_id, self.data_id, self.options + ) + ) async def async_will_remove_from_hass(self) -> None: """Unregister this entity from the Update Coordinator.""" diff --git a/homeassistant/components/kostal_plenticore/sensor.py b/homeassistant/components/kostal_plenticore/sensor.py index a9b9433c1b67..036f2baf98e2 100644 --- a/homeassistant/components/kostal_plenticore/sensor.py +++ b/homeassistant/components/kostal_plenticore/sensor.py @@ -769,7 +769,9 @@ class PlenticoreDataSensor( async def async_added_to_hass(self) -> None: """Register this entity on the Update Coordinator.""" await super().async_added_to_hass() - self.coordinator.start_fetch_data(self.module_id, self.data_id) + self.async_on_remove( + self.coordinator.start_fetch_data(self.module_id, self.data_id) + ) async def async_will_remove_from_hass(self) -> None: """Unregister this entity from the Update Coordinator.""" diff --git a/homeassistant/components/kostal_plenticore/switch.py b/homeassistant/components/kostal_plenticore/switch.py index 9dc4740e6e90..4427f4bd4e1b 100644 --- a/homeassistant/components/kostal_plenticore/switch.py +++ b/homeassistant/components/kostal_plenticore/switch.py @@ -144,7 +144,9 @@ class PlenticoreDataSwitch( async def async_added_to_hass(self) -> None: """Register this entity on the Update Coordinator.""" await super().async_added_to_hass() - self.coordinator.start_fetch_data(self.module_id, self.data_id) + self.async_on_remove( + self.coordinator.start_fetch_data(self.module_id, self.data_id) + ) async def async_will_remove_from_hass(self) -> None: """Unregister this entity from the Update Coordinator.""" From a4a795e35b7a4149428e9a709859272da8dc6253 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 4 May 2023 11:28:05 +0200 Subject: [PATCH 162/822] Fix lingering timer in kira tests (#92471) --- tests/components/kira/test_init.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/components/kira/test_init.py b/tests/components/kira/test_init.py index 3a8ad00e4eb8..50c2f4f46513 100644 --- a/tests/components/kira/test_init.py +++ b/tests/components/kira/test_init.py @@ -54,6 +54,8 @@ async def test_kira_empty_config(hass: HomeAssistant) -> None: async def test_kira_setup(hass: HomeAssistant) -> None: """Ensure platforms are loaded correctly.""" await async_setup_component(hass, kira.DOMAIN, TEST_CONFIG) + await hass.async_block_till_done() + assert len(hass.data[kira.DOMAIN]["sensor"]) == 2 assert sorted(hass.data[kira.DOMAIN]["sensor"].keys()) == [ "kira", From 7dc5d131b4cf6ec81e8d230c1cc7b5f06d6d6ce5 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 4 May 2023 11:38:24 +0200 Subject: [PATCH 163/822] Fix lingering timer in jewish_calendar (#92470) --- homeassistant/components/jewish_calendar/binary_sensor.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/homeassistant/components/jewish_calendar/binary_sensor.py b/homeassistant/components/jewish_calendar/binary_sensor.py index 3d28e2bb0c02..e127d78229fa 100644 --- a/homeassistant/components/jewish_calendar/binary_sensor.py +++ b/homeassistant/components/jewish_calendar/binary_sensor.py @@ -116,6 +116,13 @@ class JewishCalendarBinarySensor(BinarySensorEntity): await super().async_added_to_hass() self._schedule_update() + async def async_will_remove_from_hass(self) -> None: + """Run when entity will be removed from hass.""" + if self._update_unsub: + self._update_unsub() + self._update_unsub = None + return await super().async_will_remove_from_hass() + @callback def _update(self, now: datetime | None = None) -> None: """Update the state of the sensor.""" From 322c5152be977a2dbb8904fedc5f906b1e0f6d02 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 May 2023 04:50:12 -0500 Subject: [PATCH 164/822] Fix onvif setup when time set service is not functional (#92447) --- homeassistant/components/onvif/device.py | 137 ++++++++++++----------- 1 file changed, 73 insertions(+), 64 deletions(-) diff --git a/homeassistant/components/onvif/device.py b/homeassistant/components/onvif/device.py index 78e745645c57..2e0f1ca8c6f2 100644 --- a/homeassistant/components/onvif/device.py +++ b/homeassistant/components/onvif/device.py @@ -12,7 +12,7 @@ from httpx import RequestError import onvif from onvif import ONVIFCamera from onvif.exceptions import ONVIFError -from zeep.exceptions import Fault, XMLParseError +from zeep.exceptions import Fault, TransportError, XMLParseError from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -203,76 +203,85 @@ class ONVIFDevice: """Warns if device and system date not synced.""" LOGGER.debug("%s: Setting up the ONVIF device management service", self.name) device_mgmt = self.device.create_devicemgmt_service() + system_date = dt_util.utcnow() LOGGER.debug("%s: Retrieving current device date/time", self.name) try: - system_date = dt_util.utcnow() device_time = await device_mgmt.GetSystemDateAndTime() - if not device_time: - LOGGER.debug( - """Couldn't get device '%s' date/time. - GetSystemDateAndTime() return null/empty""", - self.name, - ) - return - - LOGGER.debug("%s: Device time: %s", self.name, device_time) - - tzone = dt_util.DEFAULT_TIME_ZONE - cdate = device_time.LocalDateTime - if device_time.UTCDateTime: - tzone = dt_util.UTC - cdate = device_time.UTCDateTime - elif device_time.TimeZone: - tzone = dt_util.get_time_zone(device_time.TimeZone.TZ) or tzone - - if cdate is None: - LOGGER.warning( - "%s: Could not retrieve date/time on this camera", self.name - ) - else: - cam_date = dt.datetime( - cdate.Date.Year, - cdate.Date.Month, - cdate.Date.Day, - cdate.Time.Hour, - cdate.Time.Minute, - cdate.Time.Second, - 0, - tzone, - ) - - cam_date_utc = cam_date.astimezone(dt_util.UTC) - - LOGGER.debug( - "%s: Device date/time: %s | System date/time: %s", - self.name, - cam_date_utc, - system_date, - ) - - dt_diff = cam_date - system_date - self._dt_diff_seconds = dt_diff.total_seconds() - - # It could be off either direction, so we need to check the absolute value - if abs(self._dt_diff_seconds) > 5: - LOGGER.warning( - ( - "The date/time on %s (UTC) is '%s', " - "which is different from the system '%s', " - "this could lead to authentication issues" - ), - self.name, - cam_date_utc, - system_date, - ) - if device_time.DateTimeType == "Manual": - # Set Date and Time ourselves if Date and Time is set manually in the camera. - await self.async_manually_set_date_and_time() except RequestError as err: LOGGER.warning( "Couldn't get device '%s' date/time. Error: %s", self.name, err ) + return + + if not device_time: + LOGGER.debug( + """Couldn't get device '%s' date/time. + GetSystemDateAndTime() return null/empty""", + self.name, + ) + return + + LOGGER.debug("%s: Device time: %s", self.name, device_time) + + tzone = dt_util.DEFAULT_TIME_ZONE + cdate = device_time.LocalDateTime + if device_time.UTCDateTime: + tzone = dt_util.UTC + cdate = device_time.UTCDateTime + elif device_time.TimeZone: + tzone = dt_util.get_time_zone(device_time.TimeZone.TZ) or tzone + + if cdate is None: + LOGGER.warning("%s: Could not retrieve date/time on this camera", self.name) + return + + cam_date = dt.datetime( + cdate.Date.Year, + cdate.Date.Month, + cdate.Date.Day, + cdate.Time.Hour, + cdate.Time.Minute, + cdate.Time.Second, + 0, + tzone, + ) + + cam_date_utc = cam_date.astimezone(dt_util.UTC) + + LOGGER.debug( + "%s: Device date/time: %s | System date/time: %s", + self.name, + cam_date_utc, + system_date, + ) + + dt_diff = cam_date - system_date + self._dt_diff_seconds = dt_diff.total_seconds() + + # It could be off either direction, so we need to check the absolute value + if abs(self._dt_diff_seconds) < 5: + return + + LOGGER.warning( + ( + "The date/time on %s (UTC) is '%s', " + "which is different from the system '%s', " + "this could lead to authentication issues" + ), + self.name, + cam_date_utc, + system_date, + ) + + if device_time.DateTimeType != "Manual": + return + + # Set Date and Time ourselves if Date and Time is set manually in the camera. + try: + await self.async_manually_set_date_and_time() + except (RequestError, TransportError): + LOGGER.warning("%s: Could not sync date/time on this camera", self.name) async def async_get_device_info(self) -> DeviceInfo: """Obtain information about this device.""" @@ -328,7 +337,7 @@ class ONVIFDevice: """Start the event handler.""" with suppress(*GET_CAPABILITIES_EXCEPTIONS, XMLParseError): onvif_capabilities = self.onvif_capabilities or {} - pull_point_support = onvif_capabilities.get("Events", {}).get( + pull_point_support = (onvif_capabilities.get("Events") or {}).get( "WSPullPointSupport" ) LOGGER.debug("%s: WSPullPointSupport: %s", self.name, pull_point_support) From 88019d70fe20567a211ae1f8d0f6751e228d35c0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 May 2023 05:18:08 -0500 Subject: [PATCH 165/822] Bump zeroconf to 0.62.0 (#92444) --- 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 b7c43e62d649..63d8f97d5b60 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.61.0"] + "requirements": ["zeroconf==0.62.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index bbc43a8d9348..f96c36f3bc10 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -52,7 +52,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtcvad==2.0.10 yarl==1.9.2 -zeroconf==0.61.0 +zeroconf==0.62.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 78270f38975d..4631efd51d6e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2703,7 +2703,7 @@ zamg==0.2.2 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.61.0 +zeroconf==0.62.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7a13a4ae6314..2c6d9d1fd858 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1958,7 +1958,7 @@ youless-api==1.0.1 zamg==0.2.2 # homeassistant.components.zeroconf -zeroconf==0.61.0 +zeroconf==0.62.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From 71b86e9f97096dfd51819e37850ef154c82946cc Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 4 May 2023 12:19:40 +0200 Subject: [PATCH 166/822] Fix lingering timer in sabnzbd (#92462) --- homeassistant/components/sabnzbd/__init__.py | 4 +++- tests/components/sabnzbd/conftest.py | 14 ++++++++++++++ tests/components/sabnzbd/test_config_flow.py | 12 ++++++------ 3 files changed, 23 insertions(+), 7 deletions(-) create mode 100644 tests/components/sabnzbd/conftest.py diff --git a/homeassistant/components/sabnzbd/__init__.py b/homeassistant/components/sabnzbd/__init__.py index 0b8b6e92eb34..2e345905d502 100644 --- a/homeassistant/components/sabnzbd/__init__.py +++ b/homeassistant/components/sabnzbd/__init__.py @@ -237,7 +237,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except SabnzbdApiException as err: _LOGGER.error(err) - async_track_time_interval(hass, async_update_sabnzbd, UPDATE_INTERVAL) + entry.async_on_unload( + async_track_time_interval(hass, async_update_sabnzbd, UPDATE_INTERVAL) + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/tests/components/sabnzbd/conftest.py b/tests/components/sabnzbd/conftest.py new file mode 100644 index 000000000000..01cea6066548 --- /dev/null +++ b/tests/components/sabnzbd/conftest.py @@ -0,0 +1,14 @@ +"""Configuration for Sabnzbd 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.sabnzbd.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/sabnzbd/test_config_flow.py b/tests/components/sabnzbd/test_config_flow.py index 721880dd8c95..05040186bb33 100644 --- a/tests/components/sabnzbd/test_config_flow.py +++ b/tests/components/sabnzbd/test_config_flow.py @@ -1,7 +1,8 @@ """Define tests for the Sabnzbd config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from pysabnzbd import SabnzbdApiException +import pytest from homeassistant import config_entries, data_entry_flow from homeassistant.components.sabnzbd import DOMAIN @@ -31,8 +32,10 @@ VALID_CONFIG_OLD = { CONF_SSL: False, } +pytestmark = pytest.mark.usefixtures("mock_setup_entry") -async def test_create_entry(hass: HomeAssistant) -> None: + +async def test_create_entry(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test that the user step works.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -43,10 +46,7 @@ async def test_create_entry(hass: HomeAssistant) -> None: with patch( "homeassistant.components.sabnzbd.sab.SabnzbdApi.check_available", return_value=True, - ), patch( - "homeassistant.components.sabnzbd.async_setup_entry", - return_value=True, - ) as mock_setup_entry: + ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], VALID_CONFIG, From b0b53574f93d6e72b00660696eb8c863a72c4f30 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Thu, 4 May 2023 04:21:58 -0600 Subject: [PATCH 167/822] Bump `aionotion` to 2023.05.0 (#92451) --- homeassistant/components/notion/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/notion/manifest.json b/homeassistant/components/notion/manifest.json index 7eb2ef6bba3a..1c3ffc8607ae 100644 --- a/homeassistant/components/notion/manifest.json +++ b/homeassistant/components/notion/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["aionotion"], - "requirements": ["aionotion==2023.04.2"] + "requirements": ["aionotion==2023.05.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4631efd51d6e..1dab70c8f961 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -223,7 +223,7 @@ aionanoleaf==0.2.1 aionotify==0.2.0 # homeassistant.components.notion -aionotion==2023.04.2 +aionotion==2023.05.0 # homeassistant.components.oncue aiooncue==0.3.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2c6d9d1fd858..87bf034e4c0c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -204,7 +204,7 @@ aiomusiccast==0.14.8 aionanoleaf==0.2.1 # homeassistant.components.notion -aionotion==2023.04.2 +aionotion==2023.05.0 # homeassistant.components.oncue aiooncue==0.3.4 From 9fca594777eab07b28ed3c540226517bf646fe87 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 4 May 2023 12:23:45 +0200 Subject: [PATCH 168/822] Fix lingering timer in rflink (#92460) --- homeassistant/components/rflink/__init__.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/rflink/__init__.py b/homeassistant/components/rflink/__init__.py index f5a0f0808dab..b563275297fd 100644 --- a/homeassistant/components/rflink/__init__.py +++ b/homeassistant/components/rflink/__init__.py @@ -20,7 +20,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, STATE_ON, ) -from homeassistant.core import CoreState, HomeAssistant, ServiceCall, callback +from homeassistant.core import CoreState, HassJob, HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import ( @@ -28,6 +28,7 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_send, ) from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import async_call_later from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType @@ -246,7 +247,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) @callback - def reconnect(exc=None): + def reconnect(_: Exception | None = None) -> None: """Schedule reconnect after connection has been unexpectedly lost.""" # Reset protocol binding before starting reconnect RflinkCommand.set_rflink_protocol(None) @@ -258,6 +259,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: _LOGGER.warning("Disconnected from Rflink, reconnecting") hass.async_create_task(connect()) + _reconnect_job = HassJob(reconnect, "Rflink reconnect", cancel_on_shutdown=True) + async def connect(): """Set up connection and hook it into HA for reconnect/shutdown.""" _LOGGER.info("Initiating Rflink connection") @@ -284,7 +287,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: SerialException, OSError, asyncio.TimeoutError, - ) as exc: + ): reconnect_interval = config[DOMAIN][CONF_RECONNECT_INTERVAL] _LOGGER.exception( "Error connecting to Rflink, reconnecting in %s", reconnect_interval @@ -292,7 +295,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # Connection to Rflink device is lost, make entities unavailable async_dispatcher_send(hass, SIGNAL_AVAILABILITY, False) - hass.loop.call_later(reconnect_interval, reconnect, exc) + async_call_later(hass, reconnect_interval, _reconnect_job) return # There is a valid connection to a Rflink device now so From 5ee65959989ea2936887272e88a9ee700412e6d6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 May 2023 05:24:26 -0500 Subject: [PATCH 169/822] Bump dbus-fast to 1.86.0 (#92435) --- 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 84a86754ce67..4e86a8e431ad 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -20,6 +20,6 @@ "bluetooth-adapters==0.15.3", "bluetooth-auto-recovery==1.1.1", "bluetooth-data-tools==0.4.0", - "dbus-fast==1.85.0" + "dbus-fast==1.86.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f96c36f3bc10..44aeabdb6ae7 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -19,7 +19,7 @@ bluetooth-data-tools==0.4.0 certifi>=2021.5.30 ciso8601==2.3.0 cryptography==40.0.2 -dbus-fast==1.85.0 +dbus-fast==1.86.0 fnv-hash-fast==0.3.1 ha-av==10.0.0 hass-nabucasa==0.66.2 diff --git a/requirements_all.txt b/requirements_all.txt index 1dab70c8f961..72b52ff241be 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -567,7 +567,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==1.85.0 +dbus-fast==1.86.0 # homeassistant.components.debugpy debugpy==1.6.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 87bf034e4c0c..cc612e769bed 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -456,7 +456,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==1.85.0 +dbus-fast==1.86.0 # homeassistant.components.debugpy debugpy==1.6.7 From 46bf0e59f3d124389a9663ffacf0adb8b23f68e6 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 4 May 2023 12:25:22 +0200 Subject: [PATCH 170/822] Move naming of entities in Rituals Perfume Genie (#92482) --- .../rituals_perfume_genie/binary_sensor.py | 5 ++--- .../components/rituals_perfume_genie/entity.py | 9 ++------- .../components/rituals_perfume_genie/number.py | 5 ++--- .../components/rituals_perfume_genie/select.py | 5 ++--- .../components/rituals_perfume_genie/sensor.py | 17 ++++++++--------- .../components/rituals_perfume_genie/switch.py | 3 ++- 6 files changed, 18 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/rituals_perfume_genie/binary_sensor.py b/homeassistant/components/rituals_perfume_genie/binary_sensor.py index d6c5eaeaca10..77fae53778c1 100644 --- a/homeassistant/components/rituals_perfume_genie/binary_sensor.py +++ b/homeassistant/components/rituals_perfume_genie/binary_sensor.py @@ -14,8 +14,6 @@ from .const import DOMAIN from .coordinator import RitualsDataUpdateCoordinator from .entity import DiffuserEntity -CHARGING_SUFFIX = " Battery Charging" - async def async_setup_entry( hass: HomeAssistant, @@ -42,8 +40,9 @@ class DiffuserBatteryChargingBinarySensor(DiffuserEntity, BinarySensorEntity): def __init__(self, coordinator: RitualsDataUpdateCoordinator) -> None: """Initialize the battery charging binary sensor.""" - super().__init__(coordinator, CHARGING_SUFFIX) + super().__init__(coordinator) self._attr_unique_id = f"{coordinator.diffuser.hublot}-charging" + self._attr_name = f"{coordinator.diffuser.name} Battery Charging" @property def is_on(self) -> bool: diff --git a/homeassistant/components/rituals_perfume_genie/entity.py b/homeassistant/components/rituals_perfume_genie/entity.py index 2cb1eccd4e9f..07944f4eed35 100644 --- a/homeassistant/components/rituals_perfume_genie/entity.py +++ b/homeassistant/components/rituals_perfume_genie/entity.py @@ -18,19 +18,14 @@ class DiffuserEntity(CoordinatorEntity[RitualsDataUpdateCoordinator]): def __init__( self, coordinator: RitualsDataUpdateCoordinator, - entity_suffix: str, ) -> None: """Init from config, hookup diffuser and coordinator.""" super().__init__(coordinator) - hublot = coordinator.diffuser.hublot - hubname = coordinator.diffuser.name - - self._attr_name = f"{hubname}{entity_suffix}" self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, hublot)}, + identifiers={(DOMAIN, coordinator.diffuser.hublot)}, manufacturer=MANUFACTURER, model=MODEL if coordinator.diffuser.has_battery else MODEL2, - name=hubname, + name=coordinator.diffuser.name, sw_version=coordinator.diffuser.version, ) diff --git a/homeassistant/components/rituals_perfume_genie/number.py b/homeassistant/components/rituals_perfume_genie/number.py index 6d7eb53cb59b..f107cd62488f 100644 --- a/homeassistant/components/rituals_perfume_genie/number.py +++ b/homeassistant/components/rituals_perfume_genie/number.py @@ -13,8 +13,6 @@ from .entity import DiffuserEntity MIN_PERFUME_AMOUNT = 1 MAX_PERFUME_AMOUNT = 3 -PERFUME_AMOUNT_SUFFIX = " Perfume Amount" - async def async_setup_entry( hass: HomeAssistant, @@ -39,8 +37,9 @@ class DiffuserPerfumeAmount(DiffuserEntity, NumberEntity): def __init__(self, coordinator: RitualsDataUpdateCoordinator) -> None: """Initialize the diffuser perfume amount number.""" - super().__init__(coordinator, PERFUME_AMOUNT_SUFFIX) + super().__init__(coordinator) self._attr_unique_id = f"{coordinator.diffuser.hublot}-perfume_amount" + self._attr_name = f"{coordinator.diffuser.name} Perfume Amount" @property def native_value(self) -> int: diff --git a/homeassistant/components/rituals_perfume_genie/select.py b/homeassistant/components/rituals_perfume_genie/select.py index a6ac10eadec5..96e0427c4315 100644 --- a/homeassistant/components/rituals_perfume_genie/select.py +++ b/homeassistant/components/rituals_perfume_genie/select.py @@ -11,8 +11,6 @@ from .const import DOMAIN from .coordinator import RitualsDataUpdateCoordinator from .entity import DiffuserEntity -ROOM_SIZE_SUFFIX = " Room Size" - async def async_setup_entry( hass: HomeAssistant, @@ -39,11 +37,12 @@ class DiffuserRoomSize(DiffuserEntity, SelectEntity): def __init__(self, coordinator: RitualsDataUpdateCoordinator) -> None: """Initialize the diffuser room size select entity.""" - super().__init__(coordinator, ROOM_SIZE_SUFFIX) + super().__init__(coordinator) self._attr_entity_registry_enabled_default = ( self.coordinator.diffuser.has_battery ) self._attr_unique_id = f"{coordinator.diffuser.hublot}-room_size_square_meter" + self._attr_name = f"{coordinator.diffuser.name} Room Size" @property def current_option(self) -> str: diff --git a/homeassistant/components/rituals_perfume_genie/sensor.py b/homeassistant/components/rituals_perfume_genie/sensor.py index e2a1be63adf4..c6c5c178ef51 100644 --- a/homeassistant/components/rituals_perfume_genie/sensor.py +++ b/homeassistant/components/rituals_perfume_genie/sensor.py @@ -11,11 +11,6 @@ from .const import DOMAIN from .coordinator import RitualsDataUpdateCoordinator from .entity import DiffuserEntity -BATTERY_SUFFIX = " Battery" -PERFUME_SUFFIX = " Perfume" -FILL_SUFFIX = " Fill" -WIFI_SUFFIX = " Wifi" - async def async_setup_entry( hass: HomeAssistant, @@ -47,8 +42,9 @@ class DiffuserPerfumeSensor(DiffuserEntity, SensorEntity): def __init__(self, coordinator: RitualsDataUpdateCoordinator) -> None: """Initialize the perfume sensor.""" - super().__init__(coordinator, PERFUME_SUFFIX) + super().__init__(coordinator) self._attr_unique_id = f"{coordinator.diffuser.hublot}-perfume" + self._attr_name = f"{coordinator.diffuser.name} Perfume" @property def icon(self) -> str: @@ -68,8 +64,9 @@ class DiffuserFillSensor(DiffuserEntity, SensorEntity): def __init__(self, coordinator: RitualsDataUpdateCoordinator) -> None: """Initialize the fill sensor.""" - super().__init__(coordinator, FILL_SUFFIX) + super().__init__(coordinator) self._attr_unique_id = f"{coordinator.diffuser.hublot}-fill" + self._attr_name = f"{coordinator.diffuser.name} Fill" @property def icon(self) -> str: @@ -93,8 +90,9 @@ class DiffuserBatterySensor(DiffuserEntity, SensorEntity): def __init__(self, coordinator: RitualsDataUpdateCoordinator) -> None: """Initialize the battery sensor.""" - super().__init__(coordinator, BATTERY_SUFFIX) + super().__init__(coordinator) self._attr_unique_id = f"{coordinator.diffuser.hublot}-battery_percentage" + self._attr_name = f"{coordinator.diffuser.name} Battery" @property def native_value(self) -> int: @@ -110,8 +108,9 @@ class DiffuserWifiSensor(DiffuserEntity, SensorEntity): def __init__(self, coordinator: RitualsDataUpdateCoordinator) -> None: """Initialize the wifi sensor.""" - super().__init__(coordinator, WIFI_SUFFIX) + super().__init__(coordinator) self._attr_unique_id = f"{coordinator.diffuser.hublot}-wifi_percentage" + self._attr_name = f"{coordinator.diffuser.name} Wifi" @property def native_value(self) -> int: diff --git a/homeassistant/components/rituals_perfume_genie/switch.py b/homeassistant/components/rituals_perfume_genie/switch.py index 180abb13576f..42be84f92789 100644 --- a/homeassistant/components/rituals_perfume_genie/switch.py +++ b/homeassistant/components/rituals_perfume_genie/switch.py @@ -35,9 +35,10 @@ class DiffuserSwitch(DiffuserEntity, SwitchEntity): def __init__(self, coordinator: RitualsDataUpdateCoordinator) -> None: """Initialize the diffuser switch.""" - super().__init__(coordinator, "") + super().__init__(coordinator) self._attr_is_on = self.coordinator.diffuser.is_on self._attr_unique_id = f"{coordinator.diffuser.hublot}-is_on" + self._attr_name = coordinator.diffuser.name async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" From 78da1168db2b0380a26873ff5572d92236acab80 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 4 May 2023 12:26:52 +0200 Subject: [PATCH 171/822] Adjust type hints in CloudClient (#92465) --- homeassistant/components/cloud/client.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index 631c0641b4fd..89fca4a5ea06 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -76,7 +76,7 @@ class CloudClient(Interface): return self._hass.http.runner @property - def cloudhooks(self) -> dict[str, dict[str, str]]: + def cloudhooks(self) -> dict[str, dict[str, str | bool]]: """Return list of cloudhooks.""" return self._prefs.cloudhooks @@ -97,8 +97,6 @@ class CloudClient(Interface): if self._alexa_config is not None: return self._alexa_config - assert self.cloud is not None - cloud_user = await self._prefs.get_cloud_user() alexa_conf = alexa_config.CloudAlexaConfig( @@ -120,8 +118,6 @@ class CloudClient(Interface): if self._google_config is not None: return self._google_config - assert self.cloud is not None - cloud_user = await self._prefs.get_cloud_user() google_conf = google_config.CloudGoogleConfig( @@ -270,6 +266,8 @@ class CloudClient(Interface): if payload and (region := payload.get("region")): self._relayer_region = region - async def async_cloudhooks_update(self, data: dict[str, dict[str, str]]) -> None: + async def async_cloudhooks_update( + self, data: dict[str, dict[str, str | bool]] + ) -> None: """Update local list of cloudhooks.""" await self._prefs.async_update(cloudhooks=data) From 95023ecf08f01b5ebb88fb0dab285263dd435439 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 4 May 2023 12:27:17 +0200 Subject: [PATCH 172/822] Fix lingering timer in rfxtrx (#92461) --- homeassistant/components/rfxtrx/siren.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/rfxtrx/siren.py b/homeassistant/components/rfxtrx/siren.py index c9f10febb6b4..bfff08d5ea60 100644 --- a/homeassistant/components/rfxtrx/siren.py +++ b/homeassistant/components/rfxtrx/siren.py @@ -119,6 +119,11 @@ class RfxtrxOffDelayMixin(Entity): self._timeout() self._timeout = None + async def async_will_remove_from_hass(self) -> None: + """Run when entity will be removed from hass.""" + self._cancel_timeout() + return await super().async_will_remove_from_hass() + class RfxtrxChime(RfxtrxCommandEntity, SirenEntity, RfxtrxOffDelayMixin): """Representation of a RFXtrx chime.""" From 41515249a0ba3a4a24e3fe55dd2c9a9dc46a798f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 May 2023 06:48:13 -0500 Subject: [PATCH 173/822] Fix onvif cameras with invalid encodings in device info (#92450) Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/onvif/device.py | 26 ++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/onvif/device.py b/homeassistant/components/onvif/device.py index 2e0f1ca8c6f2..f93529ea612c 100644 --- a/homeassistant/components/onvif/device.py +++ b/homeassistant/components/onvif/device.py @@ -12,7 +12,7 @@ from httpx import RequestError import onvif from onvif import ONVIFCamera from onvif.exceptions import ONVIFError -from zeep.exceptions import Fault, TransportError, XMLParseError +from zeep.exceptions import Fault, TransportError, XMLParseError, XMLSyntaxError from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -286,7 +286,21 @@ class ONVIFDevice: async def async_get_device_info(self) -> DeviceInfo: """Obtain information about this device.""" device_mgmt = self.device.create_devicemgmt_service() - device_info = await device_mgmt.GetDeviceInformation() + manufacturer = None + model = None + firmware_version = None + serial_number = None + try: + device_info = await device_mgmt.GetDeviceInformation() + except (XMLParseError, XMLSyntaxError, TransportError) as ex: + # Some cameras have invalid UTF-8 in their device information (TransportError) + # and others have completely invalid XML (XMLParseError, XMLSyntaxError) + LOGGER.warning("%s: Failed to fetch device information: %s", self.name, ex) + else: + manufacturer = device_info.Manufacturer + model = device_info.Model + firmware_version = device_info.FirmwareVersion + serial_number = device_info.SerialNumber # Grab the last MAC address for backwards compatibility mac = None @@ -306,10 +320,10 @@ class ONVIFDevice: ) return DeviceInfo( - device_info.Manufacturer, - device_info.Model, - device_info.FirmwareVersion, - device_info.SerialNumber, + manufacturer, + model, + firmware_version, + serial_number, mac, ) From a73a66bb0c8ce213850beae29a5b212e6be14edf Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 4 May 2023 13:54:57 +0200 Subject: [PATCH 174/822] Add entity descriptions to binary sensors of Rituals Perfume Genie (#92485) --- .../rituals_perfume_genie/binary_sensor.py | 62 +++++++++++++++---- 1 file changed, 50 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/rituals_perfume_genie/binary_sensor.py b/homeassistant/components/rituals_perfume_genie/binary_sensor.py index 77fae53778c1..37d60522dea6 100644 --- a/homeassistant/components/rituals_perfume_genie/binary_sensor.py +++ b/homeassistant/components/rituals_perfume_genie/binary_sensor.py @@ -1,9 +1,15 @@ """Support for Rituals Perfume Genie binary sensors.""" from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass + +from pyrituals import Diffuser + from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory @@ -15,6 +21,33 @@ from .coordinator import RitualsDataUpdateCoordinator from .entity import DiffuserEntity +@dataclass +class RitualsentityDescriptionMixin: + """Mixin values for Rituals entities.""" + + is_on_fn: Callable[[Diffuser], bool] + has_fn: Callable[[Diffuser], bool] + + +@dataclass +class RitualsBinarySensorEntityDescription( + BinarySensorEntityDescription, RitualsentityDescriptionMixin +): + """Class describing Rituals binary sensor entities.""" + + +ENTITY_DESCRIPTIONS = ( + RitualsBinarySensorEntityDescription( + key="charging", + name="Battery Charging", + device_class=BinarySensorDeviceClass.BATTERY_CHARGING, + entity_category=EntityCategory.DIAGNOSTIC, + is_on_fn=lambda diffuser: diffuser.charging, + has_fn=lambda diffuser: diffuser.has_battery, + ), +) + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -26,25 +59,30 @@ async def async_setup_entry( ] async_add_entities( - DiffuserBatteryChargingBinarySensor(coordinator) + RitualsBinarySensorEntity(coordinator, description) for coordinator in coordinators.values() - if coordinator.diffuser.has_battery + for description in ENTITY_DESCRIPTIONS + if description.has_fn(coordinator.diffuser) ) -class DiffuserBatteryChargingBinarySensor(DiffuserEntity, BinarySensorEntity): - """Representation of a diffuser battery charging binary sensor.""" +class RitualsBinarySensorEntity(DiffuserEntity, BinarySensorEntity): + """Defines a Rituals binary sensor entity.""" - _attr_device_class = BinarySensorDeviceClass.BATTERY_CHARGING - _attr_entity_category = EntityCategory.DIAGNOSTIC + entity_description: RitualsBinarySensorEntityDescription - def __init__(self, coordinator: RitualsDataUpdateCoordinator) -> None: - """Initialize the battery charging binary sensor.""" + def __init__( + self, + coordinator: RitualsDataUpdateCoordinator, + description: RitualsBinarySensorEntityDescription, + ) -> None: + """Initialize Rituals binary sensor entity.""" super().__init__(coordinator) - self._attr_unique_id = f"{coordinator.diffuser.hublot}-charging" - self._attr_name = f"{coordinator.diffuser.name} Battery Charging" + self.entity_description = description + self._attr_unique_id = f"{coordinator.diffuser.hublot}-{description.key}" + self._attr_name = f"{coordinator.diffuser.name} {description.name}" @property def is_on(self) -> bool: - """Return the state of the battery charging binary sensor.""" - return self.coordinator.diffuser.charging + """Return the state of the binary sensor.""" + return self.entity_description.is_on_fn(self.coordinator.diffuser) From 8a9b9c35e02b74192141fa0e8cfe24a654535aea Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 4 May 2023 14:06:38 +0200 Subject: [PATCH 175/822] Fix lingering timer in lifx (#92476) * Fix lingering timer in lifx * Remove duplicate code --- homeassistant/components/lifx/light.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lifx/light.py b/homeassistant/components/lifx/light.py index dd4e50d8f162..4f4223817bc1 100644 --- a/homeassistant/components/lifx/light.py +++ b/homeassistant/components/lifx/light.py @@ -170,9 +170,7 @@ class LIFXLight(LIFXEntity, LightEntity): async def update_during_transition(self, when: int) -> None: """Update state at the start and end of a transition.""" - if self.postponed_update: - self.postponed_update() - self.postponed_update = None + self._cancel_postponed_update() # Transition has started self.async_write_ha_state() @@ -327,6 +325,17 @@ class LIFXLight(LIFXEntity, LightEntity): ) return await super().async_added_to_hass() + def _cancel_postponed_update(self) -> None: + """Cancel postponed update, if applicable.""" + if self.postponed_update: + self.postponed_update() + self.postponed_update = None + + async def async_will_remove_from_hass(self) -> None: + """Run when entity will be removed from hass.""" + self._cancel_postponed_update() + return await super().async_will_remove_from_hass() + class LIFXWhite(LIFXLight): """Representation of a white-only LIFX light.""" From 9b74cb950796ad7ca7d19cd04fb8953a92cf812a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 May 2023 07:55:47 -0500 Subject: [PATCH 176/822] Fix lifx light strips when color zones are not initially populated (#92487) fixes #92456 --- homeassistant/components/lifx/coordinator.py | 13 ++++-- homeassistant/components/lifx/light.py | 2 +- tests/components/lifx/test_light.py | 44 ++++++++++++++++++++ 3 files changed, 55 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/lifx/coordinator.py b/homeassistant/components/lifx/coordinator.py index 66cea18f119c..f8964e78f632 100644 --- a/homeassistant/components/lifx/coordinator.py +++ b/homeassistant/components/lifx/coordinator.py @@ -205,13 +205,20 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator[None]): methods, DEFAULT_ATTEMPTS, OVERALL_TIMEOUT ) + def get_number_of_zones(self) -> int: + """Return the number of zones. + + If the number of zones is not yet populated, return 0 + """ + return len(self.device.color_zones) if self.device.color_zones else 0 + @callback def _async_build_color_zones_update_requests(self) -> list[Callable]: """Build a color zones update request.""" device = self.device return [ partial(device.get_color_zones, start_index=zone) - for zone in range(0, len(device.color_zones), 8) + for zone in range(0, self.get_number_of_zones(), 8) ] async def _async_update_data(self) -> None: @@ -224,7 +231,7 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator[None]): ): await self._async_populate_device_info() - num_zones = len(device.color_zones) if device.color_zones is not None else 0 + num_zones = self.get_number_of_zones() features = lifx_features(self.device) is_extended_multizone = features["extended_multizone"] is_legacy_multizone = not is_extended_multizone and features["multizone"] @@ -256,7 +263,7 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator[None]): if is_extended_multizone or is_legacy_multizone: self.active_effect = FirmwareEffect[self.device.effect.get("effect", "OFF")] - if is_legacy_multizone and num_zones != len(device.color_zones): + if is_legacy_multizone and num_zones != self.get_number_of_zones(): # The number of zones has changed so we need # to update the zones again. This happens rarely. await self.async_get_color_zones() diff --git a/homeassistant/components/lifx/light.py b/homeassistant/components/lifx/light.py index 4f4223817bc1..cb901dcbe477 100644 --- a/homeassistant/components/lifx/light.py +++ b/homeassistant/components/lifx/light.py @@ -391,7 +391,7 @@ class LIFXMultiZone(LIFXColor): """Send a color change to the bulb.""" bulb = self.bulb color_zones = bulb.color_zones - num_zones = len(color_zones) + num_zones = self.coordinator.get_number_of_zones() # Zone brightness is not reported when powered off if not self.is_on and hsbk[HSBK_BRIGHTNESS] is None: diff --git a/tests/components/lifx/test_light.py b/tests/components/lifx/test_light.py index 42c540a74ef8..fe68bd6547a7 100644 --- a/tests/components/lifx/test_light.py +++ b/tests/components/lifx/test_light.py @@ -36,6 +36,7 @@ from homeassistant.components.light import ( ATTR_TRANSITION, ATTR_XY_COLOR, DOMAIN as LIGHT_DOMAIN, + SERVICE_TURN_ON, ColorMode, ) from homeassistant.const import ( @@ -1741,3 +1742,46 @@ async def test_set_hev_cycle_state_fails_for_color_bulb(hass: HomeAssistant) -> {ATTR_ENTITY_ID: entity_id, ATTR_POWER: True}, blocking=True, ) + + +async def test_light_strip_zones_not_populated_yet(hass: HomeAssistant) -> None: + """Test a light strip were zones are not populated initially.""" + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=SERIAL + ) + already_migrated_config_entry.add_to_hass(hass) + bulb = _mocked_light_strip() + bulb.power_level = 65535 + bulb.color_zones = None + bulb.color = [65535, 65535, 65535, 65535] + with _patch_discovery(device=bulb), _patch_config_flow_try_connect( + device=bulb + ), _patch_device(device=bulb): + await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "light.my_bulb" + + state = hass.states.get(entity_id) + assert state.state == "on" + attributes = state.attributes + assert attributes[ATTR_BRIGHTNESS] == 255 + assert attributes[ATTR_COLOR_MODE] == ColorMode.HS + assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [ + ColorMode.COLOR_TEMP, + ColorMode.HS, + ] + assert attributes[ATTR_HS_COLOR] == (360.0, 100.0) + assert attributes[ATTR_RGB_COLOR] == (255, 0, 0) + assert attributes[ATTR_XY_COLOR] == (0.701, 0.299) + + await hass.services.async_call( + LIGHT_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + assert bulb.set_power.calls[0][0][0] is True + bulb.set_power.reset_mock() + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30)) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.state == STATE_ON From e14be8b0ab7d2598f538f778b748b71af109bf6d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 4 May 2023 15:53:28 +0200 Subject: [PATCH 177/822] Force migration of cloud settings to exposed_entities (#92499) --- homeassistant/components/cloud/alexa_config.py | 9 --------- homeassistant/components/cloud/google_config.py | 9 --------- tests/components/cloud/test_alexa_config.py | 2 +- tests/components/cloud/test_google_config.py | 2 +- 4 files changed, 2 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/cloud/alexa_config.py b/homeassistant/components/cloud/alexa_config.py index bfdd2e560a55..4ba32c338b5c 100644 --- a/homeassistant/components/cloud/alexa_config.py +++ b/homeassistant/components/cloud/alexa_config.py @@ -24,7 +24,6 @@ from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.homeassistant.exposed_entities import ( async_expose_entity, async_get_assistant_settings, - async_get_entity_settings, async_listen_entity_updates, async_should_expose, ) @@ -201,10 +200,6 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): return for state in self.hass.states.async_all(): - with suppress(HomeAssistantError): - entity_settings = async_get_entity_settings(self.hass, state.entity_id) - if CLOUD_ALEXA in entity_settings: - continue async_expose_entity( self.hass, CLOUD_ALEXA, @@ -212,10 +207,6 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): self._should_expose_legacy(state.entity_id), ) for entity_id in self._prefs.alexa_entity_configs: - with suppress(HomeAssistantError): - entity_settings = async_get_entity_settings(self.hass, entity_id) - if CLOUD_ALEXA in entity_settings: - continue async_expose_entity( self.hass, CLOUD_ALEXA, diff --git a/homeassistant/components/cloud/google_config.py b/homeassistant/components/cloud/google_config.py index dae1c00a33f5..16848acc19d0 100644 --- a/homeassistant/components/cloud/google_config.py +++ b/homeassistant/components/cloud/google_config.py @@ -1,6 +1,5 @@ """Google config for Cloud.""" import asyncio -from contextlib import suppress from http import HTTPStatus import logging from typing import Any @@ -178,10 +177,6 @@ class CloudGoogleConfig(AbstractConfig): for state in self.hass.states.async_all(): entity_id = state.entity_id - with suppress(HomeAssistantError): - entity_settings = async_get_entity_settings(self.hass, entity_id) - if CLOUD_GOOGLE in entity_settings: - continue async_expose_entity( self.hass, CLOUD_GOOGLE, @@ -197,10 +192,6 @@ class CloudGoogleConfig(AbstractConfig): _2fa_disabled, ) for entity_id in self._prefs.google_entity_configs: - with suppress(HomeAssistantError): - entity_settings = async_get_entity_settings(self.hass, entity_id) - if CLOUD_GOOGLE in entity_settings: - continue async_expose_entity( self.hass, CLOUD_GOOGLE, diff --git a/tests/components/cloud/test_alexa_config.py b/tests/components/cloud/test_alexa_config.py index 2a4be7e1645c..3a7e5a0874ec 100644 --- a/tests/components/cloud/test_alexa_config.py +++ b/tests/components/cloud/test_alexa_config.py @@ -628,7 +628,7 @@ async def test_alexa_config_migrate_expose_entity_prefs( "cloud.alexa": {"should_expose": True} } assert async_get_entity_settings(hass, entity_migrated.entity_id) == { - "cloud.alexa": {"should_expose": False} + "cloud.alexa": {"should_expose": True} } assert async_get_entity_settings(hass, entity_config.entity_id) == { "cloud.alexa": {"should_expose": False} diff --git a/tests/components/cloud/test_google_config.py b/tests/components/cloud/test_google_config.py index 0fa37ed9987f..45bc56a1700a 100644 --- a/tests/components/cloud/test_google_config.py +++ b/tests/components/cloud/test_google_config.py @@ -580,7 +580,7 @@ async def test_google_config_migrate_expose_entity_prefs( "cloud.google_assistant": {"should_expose": True} } assert async_get_entity_settings(hass, entity_migrated.entity_id) == { - "cloud.google_assistant": {"should_expose": False} + "cloud.google_assistant": {"should_expose": True} } assert async_get_entity_settings(hass, entity_no_2fa_exposed.entity_id) == { "cloud.google_assistant": {"disable_2fa": True, "should_expose": True} From 36cb8a71845cc8fe5b99321cedf3523ab1674437 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 May 2023 08:53:43 -0500 Subject: [PATCH 178/822] Bump bluetooth-auto-recovery 1.1.2 (#92495) Improve handling when getting the power state times out https://github.com/Bluetooth-Devices/bluetooth-auto-recovery/compare/v1.1.1...v1.1.2 --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 4e86a8e431ad..5f535819473a 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -18,7 +18,7 @@ "bleak==0.20.2", "bleak-retry-connector==3.0.2", "bluetooth-adapters==0.15.3", - "bluetooth-auto-recovery==1.1.1", + "bluetooth-auto-recovery==1.1.2", "bluetooth-data-tools==0.4.0", "dbus-fast==1.86.0" ] diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 44aeabdb6ae7..944b68df8f60 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -14,7 +14,7 @@ bcrypt==4.0.1 bleak-retry-connector==3.0.2 bleak==0.20.2 bluetooth-adapters==0.15.3 -bluetooth-auto-recovery==1.1.1 +bluetooth-auto-recovery==1.1.2 bluetooth-data-tools==0.4.0 certifi>=2021.5.30 ciso8601==2.3.0 diff --git a/requirements_all.txt b/requirements_all.txt index 72b52ff241be..669f2aa63408 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -465,7 +465,7 @@ bluemaestro-ble==0.2.3 bluetooth-adapters==0.15.3 # homeassistant.components.bluetooth -bluetooth-auto-recovery==1.1.1 +bluetooth-auto-recovery==1.1.2 # homeassistant.components.bluetooth # homeassistant.components.esphome diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cc612e769bed..9edd78f5a3c0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -385,7 +385,7 @@ bluemaestro-ble==0.2.3 bluetooth-adapters==0.15.3 # homeassistant.components.bluetooth -bluetooth-auto-recovery==1.1.1 +bluetooth-auto-recovery==1.1.2 # homeassistant.components.bluetooth # homeassistant.components.esphome From d66fabb5b5be1fb93016f45e2c32eb81f358ca61 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 4 May 2023 16:22:48 +0200 Subject: [PATCH 179/822] Update frontend to 20230503.2 (#92508) --- 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 6489b150e79d..41b363b63889 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==20230503.1"] + "requirements": ["home-assistant-frontend==20230503.2"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 944b68df8f60..a3dc5ad71232 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -25,7 +25,7 @@ ha-av==10.0.0 hass-nabucasa==0.66.2 hassil==1.0.6 home-assistant-bluetooth==1.10.0 -home-assistant-frontend==20230503.1 +home-assistant-frontend==20230503.2 home-assistant-intents==2023.4.26 httpx==0.24.0 ifaddr==0.1.7 diff --git a/requirements_all.txt b/requirements_all.txt index 669f2aa63408..d406219cca63 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -911,7 +911,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230503.1 +home-assistant-frontend==20230503.2 # homeassistant.components.conversation home-assistant-intents==2023.4.26 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9edd78f5a3c0..53ca68e12b3a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -700,7 +700,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230503.1 +home-assistant-frontend==20230503.2 # homeassistant.components.conversation home-assistant-intents==2023.4.26 From 8237d6b9f0a0d327009cc5828a4d9fb2e8e12dff Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Thu, 4 May 2023 08:35:52 -0700 Subject: [PATCH 180/822] Fix scene service examples (#92501) --- homeassistant/components/scene/services.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/scene/services.yaml b/homeassistant/components/scene/services.yaml index cbe5e70f6880..202b4a98aa94 100644 --- a/homeassistant/components/scene/services.yaml +++ b/homeassistant/components/scene/services.yaml @@ -29,7 +29,7 @@ apply: name: Entities state description: The entities and the state that they need to be. required: true - example: + example: | light.kitchen: "on" light.ceiling: state: "on" @@ -60,7 +60,7 @@ create: entities: name: Entities state description: The entities to control with the scene. - example: + example: | light.tv_back_light: "on" light.ceiling: state: "on" @@ -70,7 +70,7 @@ create: snapshot_entities: name: Snapshot entities description: The entities of which a snapshot is to be taken - example: + example: | - light.ceiling - light.kitchen selector: From 57af4672d576d46bdccb8f2acb5ad9ba9363cf8a Mon Sep 17 00:00:00 2001 From: Francesco Carnielli Date: Thu, 4 May 2023 17:36:31 +0200 Subject: [PATCH 181/822] Fix power sensor state_class in Netatmo integration (#92468) --- homeassistant/components/netatmo/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py index 25c42f92cef8..949c7336ea43 100644 --- a/homeassistant/components/netatmo/sensor.py +++ b/homeassistant/components/netatmo/sensor.py @@ -266,7 +266,7 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( netatmo_name="power", entity_registry_enabled_default=True, native_unit_of_measurement=UnitOfPower.WATT, - state_class=SensorStateClass.TOTAL, + state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER, ), ) From 887e6565706deacb3263919863a105283da0379e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 4 May 2023 19:01:41 +0200 Subject: [PATCH 182/822] Small improvement of assist_pipeline test coverage (#92115) --- .../components/assist_pipeline/pipeline.py | 39 ++++----- tests/components/assist_pipeline/test_init.py | 39 +++++++++ .../assist_pipeline/test_websocket.py | 87 +++++++++++++++++++ 3 files changed, 142 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index d347e433f463..6a4bbdf61e69 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -5,7 +5,7 @@ import asyncio from collections.abc import AsyncIterable, Callable, Iterable from dataclasses import asdict, dataclass, field import logging -from typing import Any +from typing import Any, cast import voluptuous as vol @@ -332,12 +332,12 @@ class PipelineRun: event_callback: PipelineEventCallback language: str = None # type: ignore[assignment] runner_data: Any | None = None - stt_provider: stt.SpeechToTextEntity | stt.Provider | None = None intent_agent: str | None = None - tts_engine: str | None = None tts_audio_output: str | None = None id: str = field(default_factory=ulid_util.ulid) + stt_provider: stt.SpeechToTextEntity | stt.Provider = field(init=False) + tts_engine: str = field(init=False) tts_options: dict | None = field(init=False, default=None) def __post_init__(self) -> None: @@ -388,8 +388,6 @@ class PipelineRun: async def prepare_speech_to_text(self, metadata: stt.SpeechMetadata) -> None: """Prepare speech to text.""" - stt_provider: stt.SpeechToTextEntity | stt.Provider | None = None - # pipeline.stt_engine can't be None or this function is not called stt_provider = stt.async_get_speech_to_text_engine( self.hass, @@ -422,9 +420,6 @@ class PipelineRun: stream: AsyncIterable[bytes], ) -> str: """Run speech to text portion of pipeline. Returns the spoken text.""" - if self.stt_provider is None: - raise RuntimeError("Speech to text was not prepared") - if isinstance(self.stt_provider, stt.Provider): engine = self.stt_provider.name else: @@ -547,7 +542,8 @@ class PipelineRun: async def prepare_text_to_speech(self) -> None: """Prepare text to speech.""" - engine = self.pipeline.tts_engine + # pipeline.tts_engine can't be None or this function is not called + engine = cast(str, self.pipeline.tts_engine) tts_options = {} if self.pipeline.tts_voice is not None: @@ -557,34 +553,31 @@ class PipelineRun: tts_options[tts.ATTR_AUDIO_OUTPUT] = self.tts_audio_output try: - # pipeline.tts_engine can't be None or this function is not called - if not await tts.async_support_options( + options_supported = await tts.async_support_options( self.hass, - engine, # type: ignore[arg-type] + engine, self.pipeline.tts_language, tts_options, - ): - raise TextToSpeechError( - code="tts-not-supported", - message=( - f"Text to speech engine {engine} " - f"does not support language {self.pipeline.tts_language} or options {tts_options}" - ), - ) + ) except HomeAssistantError as err: raise TextToSpeechError( code="tts-not-supported", message=f"Text to speech engine '{engine}' not found", ) from err + if not options_supported: + raise TextToSpeechError( + code="tts-not-supported", + message=( + f"Text to speech engine {engine} " + f"does not support language {self.pipeline.tts_language} or options {tts_options}" + ), + ) self.tts_engine = engine self.tts_options = tts_options async def text_to_speech(self, tts_input: str) -> str: """Run text to speech portion of pipeline. Returns URL of TTS audio.""" - if self.tts_engine is None: - raise RuntimeError("Text to speech was not prepared") - self.process_event( PipelineEvent( PipelineEventType.TTS_START, diff --git a/tests/components/assist_pipeline/test_init.py b/tests/components/assist_pipeline/test_init.py index 6fb6bf61d96e..392363fc0cc6 100644 --- a/tests/components/assist_pipeline/test_init.py +++ b/tests/components/assist_pipeline/test_init.py @@ -241,3 +241,42 @@ async def test_pipeline_from_audio_stream_no_stt( ) assert not events + + +async def test_pipeline_from_audio_stream_unknown_pipeline( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_stt_provider: MockSttProvider, + init_components, + snapshot: SnapshotAssertion, +) -> None: + """Test creating a pipeline from an audio stream. + + In this test, the pipeline does not exist. + """ + events = [] + + async def audio_data(): + yield b"part1" + yield b"part2" + yield b"" + + # Try to use the created pipeline + with pytest.raises(assist_pipeline.PipelineNotFound): + await assist_pipeline.async_pipeline_from_audio_stream( + hass, + Context(), + events.append, + stt.SpeechMetadata( + language="en-UK", + 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, + ), + audio_data(), + pipeline_id="blah", + ) + + assert not events diff --git a/tests/components/assist_pipeline/test_websocket.py b/tests/components/assist_pipeline/test_websocket.py index c71d0526fe6e..95e2c33ef5b2 100644 --- a/tests/components/assist_pipeline/test_websocket.py +++ b/tests/components/assist_pipeline/test_websocket.py @@ -7,6 +7,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.assist_pipeline.const import DOMAIN from homeassistant.components.assist_pipeline.pipeline import Pipeline, PipelineData from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from tests.typing import WebSocketGenerator @@ -430,6 +431,34 @@ async def test_stt_provider_missing( assert msg["error"]["code"] == "stt-provider-missing" +async def test_stt_provider_bad_metadata( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + mock_stt_provider, + snapshot: SnapshotAssertion, +) -> None: + """Test events from a pipeline run with wrong metadata.""" + with patch.object(mock_stt_provider, "check_metadata", return_value=False): + 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": 12345, + }, + } + ) + + # result + msg = await client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == "stt-provider-unsupported-metadata" + + async def test_stt_stream_failed( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -559,6 +588,64 @@ async def test_tts_failed( assert msg["result"] == {"events": events} +async def test_tts_provider_missing( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + mock_tts_provider, + snapshot: SnapshotAssertion, +) -> None: + """Test pipeline run with text to speech error.""" + client = await hass_ws_client(hass) + + with patch( + "homeassistant.components.tts.async_support_options", + side_effect=HomeAssistantError, + ): + await client.send_json_auto_id( + { + "type": "assist_pipeline/run", + "start_stage": "tts", + "end_stage": "tts", + "input": {"text": "Lights are on."}, + } + ) + + # result + msg = await client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == "tts-not-supported" + + +async def test_tts_provider_bad_options( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + mock_tts_provider, + snapshot: SnapshotAssertion, +) -> None: + """Test pipeline run with text to speech error.""" + client = await hass_ws_client(hass) + + with patch( + "homeassistant.components.tts.async_support_options", + return_value=False, + ): + await client.send_json_auto_id( + { + "type": "assist_pipeline/run", + "start_stage": "tts", + "end_stage": "tts", + "input": {"text": "Lights are on."}, + } + ) + + # result + msg = await client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == "tts-not-supported" + + async def test_invalid_stage_order( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components ) -> None: From b6930c0f04605eb844fc4a0482eb3e673b174a7f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 4 May 2023 19:02:52 +0200 Subject: [PATCH 183/822] Fix lingering timer in demo geo_location (#92514) --- homeassistant/components/demo/geo_location.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/demo/geo_location.py b/homeassistant/components/demo/geo_location.py index 2af7437e0f68..cd020d1bb8a1 100644 --- a/homeassistant/components/demo/geo_location.py +++ b/homeassistant/components/demo/geo_location.py @@ -86,7 +86,10 @@ class DemoManager: def _init_regular_updates(self) -> None: """Schedule regular updates based on configured time interval.""" track_time_interval( - self._hass, lambda now: self._update(), DEFAULT_UPDATE_INTERVAL + self._hass, + lambda now: self._update(), + DEFAULT_UPDATE_INTERVAL, + cancel_on_shutdown=True, ) def _update(self, count: int = 1) -> None: From 4d77c6f27fd5381fa6a20fb8d24a0668a379de7f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 4 May 2023 19:03:26 +0200 Subject: [PATCH 184/822] Fix lingering timer in elkm1 (#92517) --- homeassistant/components/elkm1/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/elkm1/__init__.py b/homeassistant/components/elkm1/__init__.py index c193f4894ba5..c20621ce60f0 100644 --- a/homeassistant/components/elkm1/__init__.py +++ b/homeassistant/components/elkm1/__init__.py @@ -188,7 +188,9 @@ async def async_setup(hass: HomeAssistant, hass_config: ConfigType) -> bool: ) hass.async_create_background_task(_async_discovery(), "elkm1 setup discovery") - async_track_time_interval(hass, _async_discovery, DISCOVERY_INTERVAL) + async_track_time_interval( + hass, _async_discovery, DISCOVERY_INTERVAL, cancel_on_shutdown=True + ) if DOMAIN not in hass_config: return True From e0903b8bc479c7edb12791adf0c460629642b00a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 4 May 2023 19:03:54 +0200 Subject: [PATCH 185/822] Fix lingering timer in duckdns (#92516) --- homeassistant/components/duckdns/__init__.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/duckdns/__init__.py b/homeassistant/components/duckdns/__init__.py index 2ba826d37e06..278c3c989db3 100644 --- a/homeassistant/components/duckdns/__init__.py +++ b/homeassistant/components/duckdns/__init__.py @@ -7,7 +7,13 @@ from typing import Any import voluptuous as vol from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DOMAIN -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, ServiceCall, callback +from homeassistant.core import ( + CALLBACK_TYPE, + HassJob, + HomeAssistant, + ServiceCall, + callback, +) from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_call_later @@ -110,7 +116,7 @@ def async_track_time_interval_backoff( """Add a listener that fires repetitively at every timedelta interval.""" if not isinstance(intervals, (list, tuple)): intervals = (intervals,) - remove = None + remove: CALLBACK_TYPE | None = None failed = 0 async def interval_listener(now: datetime) -> None: @@ -122,8 +128,11 @@ def async_track_time_interval_backoff( failed = 0 finally: delay = intervals[failed] if failed < len(intervals) else intervals[-1] - remove = async_call_later(hass, delay.total_seconds(), interval_listener) + remove = async_call_later( + hass, delay.total_seconds(), interval_listener_job + ) + interval_listener_job = HassJob(interval_listener, cancel_on_shutdown=True) hass.async_run_job(interval_listener, dt_util.utcnow()) def remove_listener() -> None: From 7987b00d89d0fd6f7aa770bb82a97479ca85d230 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 May 2023 12:05:29 -0500 Subject: [PATCH 186/822] Fix blocking I/O in the event loop when starting ONVIF (#92518) --- homeassistant/components/onvif/button.py | 2 +- homeassistant/components/onvif/config_flow.py | 4 ++-- homeassistant/components/onvif/device.py | 24 +++++++++---------- homeassistant/components/onvif/event.py | 4 ++-- homeassistant/components/onvif/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/onvif/__init__.py | 4 ++-- tests/components/onvif/test_button.py | 2 +- 9 files changed, 23 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/onvif/button.py b/homeassistant/components/onvif/button.py index cacf317f7bd7..f263821a4600 100644 --- a/homeassistant/components/onvif/button.py +++ b/homeassistant/components/onvif/button.py @@ -34,7 +34,7 @@ class RebootButton(ONVIFBaseEntity, ButtonEntity): async def async_press(self) -> None: """Send out a SystemReboot command.""" - device_mgmt = self.device.device.create_devicemgmt_service() + device_mgmt = await self.device.device.create_devicemgmt_service() await device_mgmt.SystemReboot() diff --git a/homeassistant/components/onvif/config_flow.py b/homeassistant/components/onvif/config_flow.py index 68a4ce525119..27f279266ddb 100644 --- a/homeassistant/components/onvif/config_flow.py +++ b/homeassistant/components/onvif/config_flow.py @@ -275,7 +275,7 @@ class OnvifFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): try: await device.update_xaddrs() - device_mgmt = device.create_devicemgmt_service() + device_mgmt = await device.create_devicemgmt_service() # Get the MAC address to use as the unique ID for the config flow if not self.device_id: try: @@ -314,7 +314,7 @@ class OnvifFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): } ) # Verify there is an H264 profile - media_service = device.create_media_service() + media_service = await device.create_media_service() profiles = await media_service.GetProfiles() except AttributeError: # Likely an empty document or 404 from the wrong port LOGGER.debug( diff --git a/homeassistant/components/onvif/device.py b/homeassistant/components/onvif/device.py index f93529ea612c..ea2325f271c7 100644 --- a/homeassistant/components/onvif/device.py +++ b/homeassistant/components/onvif/device.py @@ -136,7 +136,7 @@ class ONVIFDevice: if self.capabilities.ptz: LOGGER.debug("%s: creating PTZ service", self.name) - self.device.create_ptz_service() + await self.device.create_ptz_service() # Determine max resolution from profiles self.max_resolution = max( @@ -159,7 +159,7 @@ class ONVIFDevice: async def async_manually_set_date_and_time(self) -> None: """Set Date and Time Manually using SetSystemDateAndTime command.""" - device_mgmt = self.device.create_devicemgmt_service() + device_mgmt = await self.device.create_devicemgmt_service() # Retrieve DateTime object from camera to use as template for Set operation device_time = await device_mgmt.GetSystemDateAndTime() @@ -202,7 +202,7 @@ class ONVIFDevice: async def async_check_date_and_time(self) -> None: """Warns if device and system date not synced.""" LOGGER.debug("%s: Setting up the ONVIF device management service", self.name) - device_mgmt = self.device.create_devicemgmt_service() + device_mgmt = await self.device.create_devicemgmt_service() system_date = dt_util.utcnow() LOGGER.debug("%s: Retrieving current device date/time", self.name) @@ -285,7 +285,7 @@ class ONVIFDevice: async def async_get_device_info(self) -> DeviceInfo: """Obtain information about this device.""" - device_mgmt = self.device.create_devicemgmt_service() + device_mgmt = await self.device.create_devicemgmt_service() manufacturer = None model = None firmware_version = None @@ -331,7 +331,7 @@ class ONVIFDevice: """Obtain information about the available services on the device.""" snapshot = False with suppress(*GET_CAPABILITIES_EXCEPTIONS): - media_service = self.device.create_media_service() + media_service = await self.device.create_media_service() media_capabilities = await media_service.GetServiceCapabilities() snapshot = media_capabilities and media_capabilities.SnapshotUri @@ -342,7 +342,7 @@ class ONVIFDevice: imaging = False with suppress(*GET_CAPABILITIES_EXCEPTIONS): - self.device.create_imaging_service() + await self.device.create_imaging_service() imaging = True return Capabilities(snapshot=snapshot, ptz=ptz, imaging=imaging) @@ -361,7 +361,7 @@ class ONVIFDevice: async def async_get_profiles(self) -> list[Profile]: """Obtain media profiles for this device.""" - media_service = self.device.create_media_service() + media_service = await self.device.create_media_service() LOGGER.debug("%s: xaddr for media_service: %s", self.name, media_service.xaddr) try: result = await media_service.GetProfiles() @@ -408,7 +408,7 @@ class ONVIFDevice: ) try: - ptz_service = self.device.create_ptz_service() + ptz_service = await self.device.create_ptz_service() presets = await ptz_service.GetPresets(profile.token) profile.ptz.presets = [preset.token for preset in presets if preset] except GET_CAPABILITIES_EXCEPTIONS: @@ -427,7 +427,7 @@ class ONVIFDevice: async def async_get_stream_uri(self, profile: Profile) -> str: """Get the stream URI for a specified profile.""" - media_service = self.device.create_media_service() + media_service = await self.device.create_media_service() req = media_service.create_type("GetStreamUri") req.ProfileToken = profile.token req.StreamSetup = { @@ -454,7 +454,7 @@ class ONVIFDevice: LOGGER.warning("PTZ actions are not supported on device '%s'", self.name) return - ptz_service = self.device.create_ptz_service() + ptz_service = await self.device.create_ptz_service() pan_val = distance * PAN_FACTOR.get(pan, 0) tilt_val = distance * TILT_FACTOR.get(tilt, 0) @@ -576,7 +576,7 @@ class ONVIFDevice: LOGGER.warning("PTZ actions are not supported on device '%s'", self.name) return - ptz_service = self.device.create_ptz_service() + ptz_service = await self.device.create_ptz_service() LOGGER.debug( "Running Aux Command | Cmd = %s", @@ -607,7 +607,7 @@ class ONVIFDevice: ) return - imaging_service = self.device.create_imaging_service() + imaging_service = await self.device.create_imaging_service() LOGGER.debug("Setting Imaging Setting | Settings = %s", settings) try: diff --git a/homeassistant/components/onvif/event.py b/homeassistant/components/onvif/event.py index 851b0f26d1bc..92f76b6a9508 100644 --- a/homeassistant/components/onvif/event.py +++ b/homeassistant/components/onvif/event.py @@ -392,12 +392,12 @@ class PullPointManager: return False # Create subscription manager - self._pullpoint_subscription = self._device.create_subscription_service( + self._pullpoint_subscription = await self._device.create_subscription_service( "PullPointSubscription" ) # Create the service that will be used to pull messages from the device. - self._pullpoint_service = self._device.create_pullpoint_service() + self._pullpoint_service = await self._device.create_pullpoint_service() # Initialize events with suppress(*SET_SYNCHRONIZATION_POINT_ERRORS): diff --git a/homeassistant/components/onvif/manifest.json b/homeassistant/components/onvif/manifest.json index 17e7f1f0f29d..9fc0d4178380 100644 --- a/homeassistant/components/onvif/manifest.json +++ b/homeassistant/components/onvif/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/onvif", "iot_class": "local_push", "loggers": ["onvif", "wsdiscovery", "zeep"], - "requirements": ["onvif-zeep-async==1.3.1", "WSDiscovery==2.0.0"] + "requirements": ["onvif-zeep-async==2.0.0", "WSDiscovery==2.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index d406219cca63..c4889227f027 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1258,7 +1258,7 @@ ondilo==0.2.0 onkyo-eiscp==1.2.7 # homeassistant.components.onvif -onvif-zeep-async==1.3.1 +onvif-zeep-async==2.0.0 # homeassistant.components.opengarage open-garage==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 53ca68e12b3a..f3cf2a64a6cc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -945,7 +945,7 @@ omnilogic==0.4.5 ondilo==0.2.0 # homeassistant.components.onvif -onvif-zeep-async==1.3.1 +onvif-zeep-async==2.0.0 # homeassistant.components.opengarage open-garage==0.2.0 diff --git a/tests/components/onvif/__init__.py b/tests/components/onvif/__init__.py index 18de9839e1bb..a56e0a477e7a 100644 --- a/tests/components/onvif/__init__.py +++ b/tests/components/onvif/__init__.py @@ -98,8 +98,8 @@ def setup_mock_onvif_camera( ) else: mock_onvif_camera.update_xaddrs = AsyncMock(return_value=True) - mock_onvif_camera.create_devicemgmt_service = MagicMock(return_value=devicemgmt) - mock_onvif_camera.create_media_service = MagicMock(return_value=media_service) + mock_onvif_camera.create_devicemgmt_service = AsyncMock(return_value=devicemgmt) + mock_onvif_camera.create_media_service = AsyncMock(return_value=media_service) mock_onvif_camera.close = AsyncMock(return_value=None) def mock_constructor( diff --git a/tests/components/onvif/test_button.py b/tests/components/onvif/test_button.py index 4c2dda760e49..4b30bc7bdd15 100644 --- a/tests/components/onvif/test_button.py +++ b/tests/components/onvif/test_button.py @@ -27,7 +27,7 @@ async def test_reboot_button(hass: HomeAssistant) -> None: async def test_reboot_button_press(hass: HomeAssistant) -> None: """Test Reboot button press.""" _, camera, _ = await setup_onvif_integration(hass) - devicemgmt = camera.create_devicemgmt_service() + devicemgmt = await camera.create_devicemgmt_service() devicemgmt.SystemReboot = AsyncMock(return_value=True) await hass.services.async_call( From 628236209b850ce848cf627af80eb846c074a19f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 4 May 2023 19:10:44 +0200 Subject: [PATCH 187/822] Fix lingering timer in cloud (#92498) --- homeassistant/components/cloud/client.py | 6 ++++-- homeassistant/components/google_assistant/report_state.py | 4 +++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index 89fca4a5ea06..f3878fd68afb 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -16,7 +16,7 @@ from homeassistant.components.alexa import ( smart_home as alexa_smart_home, ) from homeassistant.components.google_assistant import smart_home as ga -from homeassistant.core import Context, HomeAssistant, callback +from homeassistant.core import Context, HassJob, HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later from homeassistant.util.aiohttp import MockRequest, serialize_response @@ -150,10 +150,12 @@ class CloudClient(Interface): ), err, ) - async_call_later(self._hass, 30, enable_alexa) + async_call_later(self._hass, 30, enable_alexa_job) except (alexa_errors.NoTokenAvailable, alexa_errors.RequireRelink): pass + enable_alexa_job = HassJob(enable_alexa, cancel_on_shutdown=True) + async def enable_google(_): """Enable Google.""" gconf = await self.get_google_config() diff --git a/homeassistant/components/google_assistant/report_state.py b/homeassistant/components/google_assistant/report_state.py index 737b54c8b1e9..109ea61dbab6 100644 --- a/homeassistant/components/google_assistant/report_state.py +++ b/homeassistant/components/google_assistant/report_state.py @@ -139,7 +139,9 @@ def async_enable_report_state(hass: HomeAssistant, google_config: AbstractConfig unsub = async_track_state_change(hass, MATCH_ALL, async_entity_state_listener) - unsub = async_call_later(hass, INITIAL_REPORT_DELAY, initial_report) + unsub = async_call_later( + hass, INITIAL_REPORT_DELAY, HassJob(initial_report, cancel_on_shutdown=True) + ) @callback def unsub_all(): From dd3ae96d5f783984832f98a0e31cf0a101467383 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 4 May 2023 19:13:05 +0200 Subject: [PATCH 188/822] Add entity descriptions to numbers of Rituals Perfume Genie (#92486) --- .../rituals_perfume_genie/number.py | 75 ++++++++++++++----- .../rituals_perfume_genie/test_number.py | 8 +- 2 files changed, 57 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/rituals_perfume_genie/number.py b/homeassistant/components/rituals_perfume_genie/number.py index f107cd62488f..487fd12f3391 100644 --- a/homeassistant/components/rituals_perfume_genie/number.py +++ b/homeassistant/components/rituals_perfume_genie/number.py @@ -1,7 +1,13 @@ """Support for Rituals Perfume Genie numbers.""" from __future__ import annotations -from homeassistant.components.number import NumberEntity +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any + +from pyrituals import Diffuser + +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 @@ -10,8 +16,33 @@ from .const import DOMAIN from .coordinator import RitualsDataUpdateCoordinator from .entity import DiffuserEntity -MIN_PERFUME_AMOUNT = 1 -MAX_PERFUME_AMOUNT = 3 + +@dataclass +class RitualsNumberEntityDescriptionMixin: + """Mixin for required keys.""" + + value_fn: Callable[[Diffuser], int] + set_value_fn: Callable[[Diffuser, int], Awaitable[Any]] + + +@dataclass +class RitualsNumberEntityDescription( + NumberEntityDescription, RitualsNumberEntityDescriptionMixin +): + """Class describing Rituals number entities.""" + + +ENTITY_DESCRIPTIONS = ( + RitualsNumberEntityDescription( + key="perfume_amount", + name="Perfume Amount", + icon="mdi:gauge", + native_min_value=1, + native_max_value=3, + value_fn=lambda diffuser: diffuser.perfume_amount, + set_value_fn=lambda diffuser, value: diffuser.set_perfume_amount(value), + ), +) async def async_setup_entry( @@ -24,33 +55,37 @@ async def async_setup_entry( config_entry.entry_id ] async_add_entities( - DiffuserPerfumeAmount(coordinator) for coordinator in coordinators.values() + RitualsNumberEntity(coordinator, description) + for coordinator in coordinators.values() + for description in ENTITY_DESCRIPTIONS ) -class DiffuserPerfumeAmount(DiffuserEntity, NumberEntity): - """Representation of a diffuser perfume amount number.""" +class RitualsNumberEntity(DiffuserEntity, NumberEntity): + """Representation of a diffuser number entity.""" - _attr_icon = "mdi:gauge" - _attr_native_max_value = MAX_PERFUME_AMOUNT - _attr_native_min_value = MIN_PERFUME_AMOUNT + entity_description: RitualsNumberEntityDescription - def __init__(self, coordinator: RitualsDataUpdateCoordinator) -> None: + def __init__( + self, + coordinator: RitualsDataUpdateCoordinator, + description: RitualsNumberEntityDescription, + ) -> None: """Initialize the diffuser perfume amount number.""" super().__init__(coordinator) - self._attr_unique_id = f"{coordinator.diffuser.hublot}-perfume_amount" - self._attr_name = f"{coordinator.diffuser.name} Perfume Amount" + self.entity_description = description + self._attr_unique_id = f"{coordinator.diffuser.hublot}-{description.key}" + self._attr_name = f"{coordinator.diffuser.name} {description.name}" @property def native_value(self) -> int: - """Return the current perfume amount.""" - return self.coordinator.diffuser.perfume_amount + """Return the number value.""" + return self.entity_description.value_fn(self.coordinator.diffuser) async def async_set_native_value(self, value: float) -> None: - """Set the perfume amount.""" + """Change to new number value.""" if not value.is_integer(): - raise ValueError( - f"Can't set the perfume amount to {value}. Perfume amount must be an" - " integer." - ) - await self.coordinator.diffuser.set_perfume_amount(int(value)) + raise ValueError(f"Can't set value to {value}. Value must be an integer.") + await self.entity_description.set_value_fn( + self.coordinator.diffuser, int(value) + ) diff --git a/tests/components/rituals_perfume_genie/test_number.py b/tests/components/rituals_perfume_genie/test_number.py index 550f09202ace..87d81aa8ec00 100644 --- a/tests/components/rituals_perfume_genie/test_number.py +++ b/tests/components/rituals_perfume_genie/test_number.py @@ -11,10 +11,6 @@ from homeassistant.components.number import ( DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE, ) -from homeassistant.components.rituals_perfume_genie.number import ( - MAX_PERFUME_AMOUNT, - MIN_PERFUME_AMOUNT, -) from homeassistant.const import ATTR_ENTITY_ID, ATTR_ICON from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -40,8 +36,8 @@ async def test_number_entity( assert state assert state.state == str(diffuser.perfume_amount) assert state.attributes[ATTR_ICON] == "mdi:gauge" - assert state.attributes[ATTR_MIN] == MIN_PERFUME_AMOUNT - assert state.attributes[ATTR_MAX] == MAX_PERFUME_AMOUNT + assert state.attributes[ATTR_MIN] == 1 + assert state.attributes[ATTR_MAX] == 3 entry = entity_registry.async_get("number.genie_perfume_amount") assert entry From 677ab5837f02d97896ec35c75193628673b523cd Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 4 May 2023 19:20:58 +0200 Subject: [PATCH 189/822] Add entity descriptions to switch of Rituals Perfume Genie (#92531) --- .../rituals_perfume_genie/switch.py | 64 +++++++++++++++---- 1 file changed, 51 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/rituals_perfume_genie/switch.py b/homeassistant/components/rituals_perfume_genie/switch.py index 42be84f92789..85e29f7f1ca7 100644 --- a/homeassistant/components/rituals_perfume_genie/switch.py +++ b/homeassistant/components/rituals_perfume_genie/switch.py @@ -1,9 +1,13 @@ """Support for Rituals Perfume Genie switches.""" from __future__ import annotations +from collections.abc import Awaitable, Callable +from dataclasses import dataclass from typing import Any -from homeassistant.components.switch import SwitchEntity +from pyrituals import Diffuser + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -13,6 +17,33 @@ from .coordinator import RitualsDataUpdateCoordinator from .entity import DiffuserEntity +@dataclass +class RitualsEntityDescriptionMixin: + """Mixin values for Rituals entities.""" + + is_on_fn: Callable[[Diffuser], bool] + turn_on_fn: Callable[[Diffuser], Awaitable[None]] + turn_off_fn: Callable[[Diffuser], Awaitable[None]] + + +@dataclass +class RitualsSwitchEntityDescription( + SwitchEntityDescription, RitualsEntityDescriptionMixin +): + """Class describing Rituals switch entities.""" + + +ENTITY_DESCRIPTIONS = ( + RitualsSwitchEntityDescription( + key="is_on", + icon="mdi:fan", + is_on_fn=lambda diffuser: diffuser.is_on, + turn_on_fn=lambda diffuser: diffuser.turn_on(), + turn_off_fn=lambda diffuser: diffuser.turn_off(), + ), +) + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -24,36 +55,43 @@ async def async_setup_entry( ] async_add_entities( - DiffuserSwitch(coordinator) for coordinator in coordinators.values() + RitualsSwitchEntity(coordinator, description) + for coordinator in coordinators.values() + for description in ENTITY_DESCRIPTIONS ) -class DiffuserSwitch(DiffuserEntity, SwitchEntity): +class RitualsSwitchEntity(DiffuserEntity, SwitchEntity): """Representation of a diffuser switch.""" - _attr_icon = "mdi:fan" + entity_description: RitualsSwitchEntityDescription - def __init__(self, coordinator: RitualsDataUpdateCoordinator) -> None: + def __init__( + self, + coordinator: RitualsDataUpdateCoordinator, + description: RitualsSwitchEntityDescription, + ) -> None: """Initialize the diffuser switch.""" super().__init__(coordinator) - self._attr_is_on = self.coordinator.diffuser.is_on - self._attr_unique_id = f"{coordinator.diffuser.hublot}-is_on" + self.entity_description = description + self._attr_unique_id = f"{coordinator.diffuser.hublot}-{description.key}" self._attr_name = coordinator.diffuser.name + self._attr_is_on = description.is_on_fn(coordinator.diffuser) async def async_turn_on(self, **kwargs: Any) -> None: - """Turn the device on.""" - await self.coordinator.diffuser.turn_on() + """Turn the switch on.""" + await self.entity_description.turn_on_fn(self.coordinator.diffuser) 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.coordinator.diffuser.turn_off() + """Turn the switch off.""" + await self.entity_description.turn_off_fn(self.coordinator.diffuser) self._attr_is_on = False self.async_write_ha_state() @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" - self._attr_is_on = self.coordinator.diffuser.is_on - self.async_write_ha_state() + self._attr_is_on = self.entity_description.is_on_fn(self.coordinator.diffuser) + super()._handle_coordinator_update() From 56dcb908bc725dbc87193d3baf360023695d973e Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 4 May 2023 19:48:32 +0200 Subject: [PATCH 190/822] Wait for mqtt client to become available (#92524) --- homeassistant/components/arwn/sensor.py | 5 +++++ homeassistant/components/mqtt_eventstream/__init__.py | 8 ++++++++ homeassistant/components/mqtt_statestream/__init__.py | 5 +++++ tests/components/mqtt_eventstream/test_init.py | 10 ++++++++++ 4 files changed, 28 insertions(+) diff --git a/homeassistant/components/arwn/sensor.py b/homeassistant/components/arwn/sensor.py index f03734e63dff..d468a93eca05 100644 --- a/homeassistant/components/arwn/sensor.py +++ b/homeassistant/components/arwn/sensor.py @@ -101,6 +101,11 @@ async def async_setup_platform( ) -> None: """Set up the ARWN platform.""" + # Make sure MQTT integration is enabled and the client is available + if not await mqtt.async_wait_for_mqtt_client(hass): + _LOGGER.error("MQTT integration is not available") + return + @callback def async_sensor_event_received(msg: mqtt.ReceiveMessage) -> None: """Process events as sensors. diff --git a/homeassistant/components/mqtt_eventstream/__init__.py b/homeassistant/components/mqtt_eventstream/__init__.py index 62a10c1bd000..af370fe82f3d 100644 --- a/homeassistant/components/mqtt_eventstream/__init__.py +++ b/homeassistant/components/mqtt_eventstream/__init__.py @@ -1,5 +1,6 @@ """Connect two Home Assistant instances via MQTT.""" import json +import logging import voluptuous as vol @@ -21,6 +22,8 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.json import JSONEncoder from homeassistant.helpers.typing import ConfigType +_LOGGER = logging.getLogger(__name__) + DOMAIN = "mqtt_eventstream" CONF_PUBLISH_TOPIC = "publish_topic" CONF_SUBSCRIBE_TOPIC = "subscribe_topic" @@ -54,6 +57,11 @@ BLOCKED_EVENTS = [ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the MQTT eventstream component.""" + # Make sure MQTT integration is enabled and the client is available + if not await mqtt.async_wait_for_mqtt_client(hass): + _LOGGER.error("MQTT integration is not available") + return False + conf = config.get(DOMAIN, {}) pub_topic = conf.get(CONF_PUBLISH_TOPIC) sub_topic = conf.get(CONF_SUBSCRIBE_TOPIC) diff --git a/homeassistant/components/mqtt_statestream/__init__.py b/homeassistant/components/mqtt_statestream/__init__.py index aa4c2c628b4c..328368258765 100644 --- a/homeassistant/components/mqtt_statestream/__init__.py +++ b/homeassistant/components/mqtt_statestream/__init__.py @@ -41,6 +41,11 @@ _LOGGER = logging.getLogger(__name__) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the MQTT state feed.""" + # Make sure MQTT integration is enabled and the client is available + if not await mqtt.async_wait_for_mqtt_client(hass): + _LOGGER.error("MQTT integration is not available") + return False + conf: ConfigType = config[DOMAIN] publish_filter = convert_include_exclude_filter(conf) base_topic: str = conf[CONF_BASE_TOPIC] diff --git a/tests/components/mqtt_eventstream/test_init.py b/tests/components/mqtt_eventstream/test_init.py index 6e382036a1e5..a61ea692bf2e 100644 --- a/tests/components/mqtt_eventstream/test_init.py +++ b/tests/components/mqtt_eventstream/test_init.py @@ -2,6 +2,8 @@ import json from unittest.mock import ANY, patch +import pytest + import homeassistant.components.mqtt_eventstream as eventstream from homeassistant.const import EVENT_STATE_CHANGED, MATCH_ALL from homeassistant.core import HomeAssistant, State, callback @@ -36,6 +38,14 @@ async def test_setup_succeeds(hass: HomeAssistant, mqtt_mock: MqttMockHAClient) assert await add_eventstream(hass) +async def test_setup_no_mqtt( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test the failure of the setup if mqtt is not set up.""" + assert not await add_eventstream(hass) + assert "MQTT integration is not available" in caplog.text + + async def test_setup_with_pub(hass: HomeAssistant, mqtt_mock: MqttMockHAClient) -> None: """Test the setup with subscription.""" # Should start off with no listeners for all events From a72bcfde4a2effc2978734ba37efff22206fe517 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 4 May 2023 19:50:43 +0200 Subject: [PATCH 191/822] Refactor multipan options flow (#92175) --- .../silabs_multiprotocol_addon.py | 182 ++++++++---------- 1 file changed, 76 insertions(+), 106 deletions(-) diff --git a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py index ff2bf9138f53..8c502f080f6c 100644 --- a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py +++ b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py @@ -22,12 +22,7 @@ from homeassistant.components.hassio import ( from homeassistant.components.zha import DOMAIN as ZHA_DOMAIN from homeassistant.components.zha.radio_manager import ZhaMultiPANMigrationHelper from homeassistant.core import HomeAssistant, callback -from homeassistant.data_entry_flow import ( - AbortFlow, - FlowHandler, - FlowManager, - FlowResult, -) +from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.singleton import singleton @@ -82,20 +77,18 @@ def is_multiprotocol_url(url: str) -> bool: return parsed.host == hostname -class BaseMultiPanFlow(FlowHandler, ABC): - """Support configuring the Silicon Labs Multiprotocol add-on.""" +class OptionsFlowHandler(config_entries.OptionsFlow, ABC): + """Handle an options flow for the Silicon Labs Multiprotocol add-on.""" - def __init__(self) -> None: - """Set up flow instance.""" + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Set up the options flow.""" # If we install the add-on we should uninstall it on entry remove. self.install_task: asyncio.Task | None = None self.start_task: asyncio.Task | None = None self._zha_migration_mgr: ZhaMultiPANMigrationHelper | None = None - - @property - @abstractmethod - def flow_manager(self) -> FlowManager: - """Return the flow manager of the flow.""" + self.config_entry = config_entry + self.original_addon_config: dict[str, Any] | None = None + self.revert_reason: str | None = None @abstractmethod async def _async_serial_port_settings(self) -> SerialPortSettings: @@ -117,81 +110,10 @@ class BaseMultiPanFlow(FlowHandler, ABC): def _zha_name(self) -> str: """Return the ZHA name.""" - async def async_step_install_addon( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Install Silicon Labs Multiprotocol add-on.""" - if not self.install_task: - self.install_task = self.hass.async_create_task(self._async_install_addon()) - return self.async_show_progress( - step_id="install_addon", progress_action="install_addon" - ) - - try: - await self.install_task - except AddonError as err: - self.install_task = None - _LOGGER.error(err) - return self.async_show_progress_done(next_step_id="install_failed") - - self.install_task = None - - return self.async_show_progress_done(next_step_id="configure_addon") - - async def async_step_install_failed( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Add-on installation failed.""" - return self.async_abort(reason="addon_install_failed") - - async def async_step_start_addon( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Start Silicon Labs Multiprotocol add-on.""" - if not self.start_task: - self.start_task = self.hass.async_create_task(self._async_start_addon()) - return self.async_show_progress( - step_id="start_addon", progress_action="start_addon" - ) - - try: - await self.start_task - except (AddonError, AbortFlow) as err: - self.start_task = None - _LOGGER.error(err) - return self.async_show_progress_done(next_step_id="start_failed") - - self.start_task = None - return self.async_show_progress_done(next_step_id="finish_addon_setup") - - async def async_step_start_failed( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Add-on start failed.""" - return self.async_abort(reason="addon_start_failed") - - async def _async_start_addon(self) -> None: - """Start Silicon Labs Multiprotocol add-on.""" - addon_manager: AddonManager = get_addon_manager(self.hass) - try: - await addon_manager.async_schedule_start_addon() - finally: - # Continue the flow after show progress when the task is done. - self.hass.async_create_task( - self.flow_manager.async_configure(flow_id=self.flow_id) - ) - - @abstractmethod - async def async_step_configure_addon( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Configure the Silicon Labs Multiprotocol add-on.""" - - @abstractmethod - async def async_step_finish_addon_setup( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Finish setup of the Silicon Labs Multiprotocol add-on.""" + @property + def flow_manager(self) -> config_entries.OptionsFlowManager: + """Return the correct flow manager.""" + return self.hass.config_entries.options async def _async_get_addon_info(self) -> AddonInfo: """Return and cache Silicon Labs Multiprotocol add-on info.""" @@ -224,22 +146,6 @@ class BaseMultiPanFlow(FlowHandler, ABC): self.flow_manager.async_configure(flow_id=self.flow_id) ) - -class OptionsFlowHandler(BaseMultiPanFlow, config_entries.OptionsFlow): - """Handle an options flow for the Silicon Labs Multiprotocol add-on.""" - - def __init__(self, config_entry: config_entries.ConfigEntry) -> None: - """Set up the options flow.""" - super().__init__() - self.config_entry = config_entry - self.original_addon_config: dict[str, Any] | None = None - self.revert_reason: str | None = None - - @property - def flow_manager(self) -> config_entries.OptionsFlowManager: - """Return the correct flow manager.""" - return self.hass.config_entries.options - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -276,6 +182,33 @@ class OptionsFlowHandler(BaseMultiPanFlow, config_entries.OptionsFlow): return await self.async_step_install_addon() + async def async_step_install_addon( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Install Silicon Labs Multiprotocol add-on.""" + if not self.install_task: + self.install_task = self.hass.async_create_task(self._async_install_addon()) + return self.async_show_progress( + step_id="install_addon", progress_action="install_addon" + ) + + try: + await self.install_task + except AddonError as err: + self.install_task = None + _LOGGER.error(err) + return self.async_show_progress_done(next_step_id="install_failed") + + self.install_task = None + + return self.async_show_progress_done(next_step_id="configure_addon") + + async def async_step_install_failed( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Add-on installation failed.""" + return self.async_abort(reason="addon_install_failed") + async def async_step_configure_addon( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -322,6 +255,43 @@ class OptionsFlowHandler(BaseMultiPanFlow, config_entries.OptionsFlow): return await self.async_step_start_addon() + async def async_step_start_addon( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Start Silicon Labs Multiprotocol add-on.""" + if not self.start_task: + self.start_task = self.hass.async_create_task(self._async_start_addon()) + return self.async_show_progress( + step_id="start_addon", progress_action="start_addon" + ) + + try: + await self.start_task + except (AddonError, AbortFlow) as err: + self.start_task = None + _LOGGER.error(err) + return self.async_show_progress_done(next_step_id="start_failed") + + self.start_task = None + return self.async_show_progress_done(next_step_id="finish_addon_setup") + + async def async_step_start_failed( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Add-on start failed.""" + return self.async_abort(reason="addon_start_failed") + + async def _async_start_addon(self) -> None: + """Start Silicon Labs Multiprotocol add-on.""" + addon_manager: AddonManager = get_addon_manager(self.hass) + try: + await addon_manager.async_schedule_start_addon() + finally: + # Continue the flow after show progress when the task is done. + self.hass.async_create_task( + self.flow_manager.async_configure(flow_id=self.flow_id) + ) + async def async_step_finish_addon_setup( self, user_input: dict[str, Any] | None = None ) -> FlowResult: From 80e92195464fde15267845703d90adff1841c530 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 4 May 2023 20:12:52 +0200 Subject: [PATCH 192/822] Add entity descriptions to sensors of Rituals Perfume Genie (#92527) --- .../rituals_perfume_genie/sensor.py | 165 +++++++++--------- .../rituals_perfume_genie/test_sensor.py | 20 +-- 2 files changed, 79 insertions(+), 106 deletions(-) diff --git a/homeassistant/components/rituals_perfume_genie/sensor.py b/homeassistant/components/rituals_perfume_genie/sensor.py index c6c5c178ef51..cd637a2284fd 100644 --- a/homeassistant/components/rituals_perfume_genie/sensor.py +++ b/homeassistant/components/rituals_perfume_genie/sensor.py @@ -1,7 +1,16 @@ """Support for Rituals Perfume Genie sensors.""" from __future__ import annotations -from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from collections.abc import Callable +from dataclasses import dataclass + +from pyrituals import Diffuser + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant @@ -12,6 +21,53 @@ from .coordinator import RitualsDataUpdateCoordinator from .entity import DiffuserEntity +@dataclass +class RitualsEntityDescriptionMixin: + """Mixin values for Rituals entities.""" + + value_fn: Callable[[Diffuser], int | str] + + +@dataclass +class RitualsSensorEntityDescription( + SensorEntityDescription, RitualsEntityDescriptionMixin +): + """Class describing Rituals sensor entities.""" + + has_fn: Callable[[Diffuser], bool] = lambda _: True + + +ENTITY_DESCRIPTIONS = ( + RitualsSensorEntityDescription( + key="battery_percentage", + name="Battery", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + value_fn=lambda diffuser: diffuser.battery_percentage, + has_fn=lambda diffuser: diffuser.has_battery, + ), + RitualsSensorEntityDescription( + key="fill", + name="Fill", + icon="mdi:beaker", + value_fn=lambda diffuser: diffuser.fill, + ), + RitualsSensorEntityDescription( + key="perfume", + name="Perfume", + icon="mdi:tag", + value_fn=lambda diffuser: diffuser.perfume, + ), + RitualsSensorEntityDescription( + key="wifi_percentage", + name="Wifi", + icon="mdi:wifi", + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda diffuser: diffuser.wifi_percentage, + ), +) + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -22,97 +78,32 @@ async def async_setup_entry( config_entry.entry_id ] - entities: list[DiffuserEntity] = [] - for coordinator in coordinators.values(): - entities.extend( - [ - DiffuserPerfumeSensor(coordinator), - DiffuserFillSensor(coordinator), - DiffuserWifiSensor(coordinator), - ] - ) - if coordinator.diffuser.has_battery: - entities.append(DiffuserBatterySensor(coordinator)) - - async_add_entities(entities) + async_add_entities( + RitualsSensorEntity(coordinator, description) + for coordinator in coordinators.values() + for description in ENTITY_DESCRIPTIONS + if description.has_fn(coordinator.diffuser) + ) -class DiffuserPerfumeSensor(DiffuserEntity, SensorEntity): - """Representation of a diffuser perfume sensor.""" +class RitualsSensorEntity(DiffuserEntity, SensorEntity): + """Representation of a diffuser sensor.""" - def __init__(self, coordinator: RitualsDataUpdateCoordinator) -> None: - """Initialize the perfume sensor.""" - super().__init__(coordinator) - self._attr_unique_id = f"{coordinator.diffuser.hublot}-perfume" - self._attr_name = f"{coordinator.diffuser.name} Perfume" - - @property - def icon(self) -> str: - """Return the perfume sensor icon.""" - if self.coordinator.diffuser.has_cartridge: - return "mdi:tag-text" - return "mdi:tag-remove" - - @property - def native_value(self) -> str: - """Return the state of the perfume sensor.""" - return self.coordinator.diffuser.perfume - - -class DiffuserFillSensor(DiffuserEntity, SensorEntity): - """Representation of a diffuser fill sensor.""" - - def __init__(self, coordinator: RitualsDataUpdateCoordinator) -> None: - """Initialize the fill sensor.""" - super().__init__(coordinator) - self._attr_unique_id = f"{coordinator.diffuser.hublot}-fill" - self._attr_name = f"{coordinator.diffuser.name} Fill" - - @property - def icon(self) -> str: - """Return the fill sensor icon.""" - if self.coordinator.diffuser.has_cartridge: - return "mdi:beaker" - return "mdi:beaker-question" - - @property - def native_value(self) -> str: - """Return the state of the fill sensor.""" - return self.coordinator.diffuser.fill - - -class DiffuserBatterySensor(DiffuserEntity, SensorEntity): - """Representation of a diffuser battery sensor.""" - - _attr_device_class = SensorDeviceClass.BATTERY - _attr_native_unit_of_measurement = PERCENTAGE + entity_description: RitualsSensorEntityDescription _attr_entity_category = EntityCategory.DIAGNOSTIC - def __init__(self, coordinator: RitualsDataUpdateCoordinator) -> None: - """Initialize the battery sensor.""" + def __init__( + self, + coordinator: RitualsDataUpdateCoordinator, + description: RitualsSensorEntityDescription, + ) -> None: + """Initialize the diffuser sensor.""" super().__init__(coordinator) - self._attr_unique_id = f"{coordinator.diffuser.hublot}-battery_percentage" - self._attr_name = f"{coordinator.diffuser.name} Battery" + self.entity_description = description + self._attr_unique_id = f"{coordinator.diffuser.hublot}-{description.key}" + self._attr_name = f"{coordinator.diffuser.name} {description.name}" @property - def native_value(self) -> int: - """Return the state of the battery sensor.""" - return self.coordinator.diffuser.battery_percentage - - -class DiffuserWifiSensor(DiffuserEntity, SensorEntity): - """Representation of a diffuser wifi sensor.""" - - _attr_native_unit_of_measurement = PERCENTAGE - _attr_entity_category = EntityCategory.DIAGNOSTIC - - def __init__(self, coordinator: RitualsDataUpdateCoordinator) -> None: - """Initialize the wifi sensor.""" - super().__init__(coordinator) - self._attr_unique_id = f"{coordinator.diffuser.hublot}-wifi_percentage" - self._attr_name = f"{coordinator.diffuser.name} Wifi" - - @property - def native_value(self) -> int: - """Return the state of the wifi sensor.""" - return self.coordinator.diffuser.wifi_percentage + def native_value(self) -> str | int: + """Return the sensor value.""" + return self.entity_description.value_fn(self.coordinator.diffuser) diff --git a/tests/components/rituals_perfume_genie/test_sensor.py b/tests/components/rituals_perfume_genie/test_sensor.py index dbbd83d1df31..af6d501d36ad 100644 --- a/tests/components/rituals_perfume_genie/test_sensor.py +++ b/tests/components/rituals_perfume_genie/test_sensor.py @@ -14,7 +14,6 @@ from .common import ( init_integration, mock_config_entry, mock_diffuser_v1_battery_cartridge, - mock_diffuser_v2_no_battery_no_cartridge, ) @@ -30,7 +29,7 @@ async def test_sensors_diffuser_v1_battery_cartridge( state = hass.states.get("sensor.genie_perfume") assert state assert state.state == diffuser.perfume - assert state.attributes.get(ATTR_ICON) == "mdi:tag-text" + assert state.attributes.get(ATTR_ICON) == "mdi:tag" entry = entity_registry.async_get("sensor.genie_perfume") assert entry @@ -66,20 +65,3 @@ async def test_sensors_diffuser_v1_battery_cartridge( assert entry assert entry.unique_id == f"{hublot}-wifi_percentage" assert entry.entity_category == EntityCategory.DIAGNOSTIC - - -async def test_sensors_diffuser_v2_no_battery_no_cartridge(hass: HomeAssistant) -> None: - """Test the creation and values of the Rituals Perfume Genie sensors.""" - config_entry = mock_config_entry(unique_id="id_123_sensor_test_diffuser_v2") - - await init_integration( - hass, config_entry, [mock_diffuser_v2_no_battery_no_cartridge()] - ) - - state = hass.states.get("sensor.genie_v2_perfume") - assert state - assert state.attributes.get(ATTR_ICON) == "mdi:tag-remove" - - state = hass.states.get("sensor.genie_v2_fill") - assert state - assert state.attributes.get(ATTR_ICON) == "mdi:beaker-question" From 7d00804f1353c622621a1f4e2cdbc1c82b9a9c3b Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 4 May 2023 20:13:04 +0200 Subject: [PATCH 193/822] Add entity descriptions to selects of Rituals Perfume Genie (#92522) --- .../rituals_perfume_genie/select.py | 70 +++++++++++++++---- 1 file changed, 55 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/rituals_perfume_genie/select.py b/homeassistant/components/rituals_perfume_genie/select.py index 96e0427c4315..f721f2e6e29f 100644 --- a/homeassistant/components/rituals_perfume_genie/select.py +++ b/homeassistant/components/rituals_perfume_genie/select.py @@ -1,7 +1,12 @@ """Support for Rituals Perfume Genie numbers.""" from __future__ import annotations -from homeassistant.components.select import SelectEntity +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + +from pyrituals import Diffuser + +from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.const import AREA_SQUARE_METERS, EntityCategory from homeassistant.core import HomeAssistant @@ -12,6 +17,37 @@ from .coordinator import RitualsDataUpdateCoordinator from .entity import DiffuserEntity +@dataclass +class RitualsEntityDescriptionMixin: + """Mixin for required keys.""" + + current_fn: Callable[[Diffuser], str] + select_fn: Callable[[Diffuser, str], Awaitable[None]] + + +@dataclass +class RitualsSelectEntityDescription( + SelectEntityDescription, RitualsEntityDescriptionMixin +): + """Class describing Rituals select entities.""" + + +ENTITY_DESCRIPTIONS = ( + RitualsSelectEntityDescription( + key="room_size_square_meter", + name="Room Size", + icon="mdi:ruler-square", + unit_of_measurement=AREA_SQUARE_METERS, + entity_category=EntityCategory.CONFIG, + options=["15", "30", "60", "100"], + current_fn=lambda diffuser: str(diffuser.room_size_square_meter), + select_fn=lambda diffuser, value: ( + diffuser.set_room_size_square_meter(int(value)) + ), + ), +) + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -23,32 +59,36 @@ async def async_setup_entry( ] async_add_entities( - DiffuserRoomSize(coordinator) for coordinator in coordinators.values() + RitualsSelectEntity(coordinator, description) + for coordinator in coordinators.values() + for description in ENTITY_DESCRIPTIONS ) -class DiffuserRoomSize(DiffuserEntity, SelectEntity): - """Representation of a diffuser room size select entity.""" +class RitualsSelectEntity(DiffuserEntity, SelectEntity): + """Representation of a diffuser select entity.""" - _attr_icon = "mdi:ruler-square" - _attr_unit_of_measurement = AREA_SQUARE_METERS - _attr_options = ["15", "30", "60", "100"] - _attr_entity_category = EntityCategory.CONFIG + entity_description: RitualsSelectEntityDescription - def __init__(self, coordinator: RitualsDataUpdateCoordinator) -> None: + def __init__( + self, + coordinator: RitualsDataUpdateCoordinator, + description: RitualsSelectEntityDescription, + ) -> None: """Initialize the diffuser room size select entity.""" super().__init__(coordinator) + self.entity_description = description self._attr_entity_registry_enabled_default = ( self.coordinator.diffuser.has_battery ) - self._attr_unique_id = f"{coordinator.diffuser.hublot}-room_size_square_meter" - self._attr_name = f"{coordinator.diffuser.name} Room Size" + self._attr_unique_id = f"{coordinator.diffuser.hublot}-{description.key}" + self._attr_name = f"{coordinator.diffuser.name} {description.name}" @property def current_option(self) -> str: - """Return the diffuser room size.""" - return str(self.coordinator.diffuser.room_size_square_meter) + """Return the selected entity option to represent the entity state.""" + return self.entity_description.current_fn(self.coordinator.diffuser) async def async_select_option(self, option: str) -> None: - """Change the diffuser room size.""" - await self.coordinator.diffuser.set_room_size_square_meter(int(option)) + """Change the selected option.""" + await self.entity_description.select_fn(self.coordinator.diffuser, option) From 7b8936b4202665328e6b8e5d50557cb9899e3249 Mon Sep 17 00:00:00 2001 From: rrooggiieerr Date: Thu, 4 May 2023 21:41:32 +0200 Subject: [PATCH 194/822] Add support for setting NumberMode using NumberEntityDescription (#92427) --- homeassistant/components/baf/number.py | 2 -- .../components/bmw_connected_drive/number.py | 2 -- homeassistant/components/number/__init__.py | 16 ++++++++++++---- homeassistant/components/shelly/number.py | 3 +-- 4 files changed, 13 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/baf/number.py b/homeassistant/components/baf/number.py index 91fe110d388a..020f34fefafb 100644 --- a/homeassistant/components/baf/number.py +++ b/homeassistant/components/baf/number.py @@ -27,7 +27,6 @@ class BAFNumberDescriptionMixin: """Required values for BAF sensors.""" value_fn: Callable[[Device], int | None] - mode: NumberMode @dataclass @@ -147,7 +146,6 @@ class BAFNumber(BAFEntity, NumberEntity): self.entity_description = description super().__init__(device, f"{device.name} {description.name}") self._attr_unique_id = f"{self._device.mac_address}-{description.key}" - self._attr_mode = description.mode @callback def _async_update_attrs(self) -> None: diff --git a/homeassistant/components/bmw_connected_drive/number.py b/homeassistant/components/bmw_connected_drive/number.py index f26a2027f72c..820257f61632 100644 --- a/homeassistant/components/bmw_connected_drive/number.py +++ b/homeassistant/components/bmw_connected_drive/number.py @@ -40,7 +40,6 @@ class BMWNumberEntityDescription(NumberEntityDescription, BMWRequiredKeysMixin): is_available: Callable[[MyBMWVehicle], bool] = lambda _: False dynamic_options: Callable[[MyBMWVehicle], list[str]] | None = None - mode: NumberMode = NumberMode.AUTO NUMBER_TYPES: list[BMWNumberEntityDescription] = [ @@ -99,7 +98,6 @@ class BMWNumber(BMWBaseEntity, NumberEntity): super().__init__(coordinator, vehicle) self.entity_description = description self._attr_unique_id = f"{vehicle.vin}-{description.key}" - self._attr_mode = description.mode @property def native_value(self) -> float | None: diff --git a/homeassistant/components/number/__init__.py b/homeassistant/components/number/__init__.py index e090a21a23ee..2ad63c75e040 100644 --- a/homeassistant/components/number/__init__.py +++ b/homeassistant/components/number/__init__.py @@ -127,10 +127,11 @@ class NumberEntityDescription(EntityDescription): device_class: NumberDeviceClass | None = None max_value: None = None min_value: None = None + mode: NumberMode | None = None native_max_value: float | None = None native_min_value: float | None = None - native_unit_of_measurement: str | None = None native_step: float | None = None + native_unit_of_measurement: str | None = None step: None = None unit_of_measurement: None = None # Type override, use native_unit_of_measurement @@ -193,7 +194,7 @@ class NumberEntity(Entity): _attr_device_class: NumberDeviceClass | None _attr_max_value: None _attr_min_value: None - _attr_mode: NumberMode = NumberMode.AUTO + _attr_mode: NumberMode _attr_state: None = None _attr_step: None _attr_unit_of_measurement: None # Subclasses of NumberEntity should not set this @@ -201,8 +202,8 @@ class NumberEntity(Entity): _attr_native_max_value: float _attr_native_min_value: float _attr_native_step: float - _attr_native_value: float | None = None _attr_native_unit_of_measurement: str | None + _attr_native_value: float | None = None _deprecated_number_entity_reported = False _number_option_unit_of_measurement: str | None = None @@ -357,7 +358,14 @@ class NumberEntity(Entity): @property def mode(self) -> NumberMode: """Return the mode of the entity.""" - return self._attr_mode + if hasattr(self, "_attr_mode"): + return self._attr_mode + if ( + hasattr(self, "entity_description") + and self.entity_description.mode is not None + ): + return self.entity_description.mode + return NumberMode.AUTO @property @final diff --git a/homeassistant/components/shelly/number.py b/homeassistant/components/shelly/number.py index 5a00a1df0ddc..fb95a180e76c 100644 --- a/homeassistant/components/shelly/number.py +++ b/homeassistant/components/shelly/number.py @@ -30,7 +30,6 @@ from .entity import ( class BlockNumberDescription(BlockEntityDescription, NumberEntityDescription): """Class to describe a BLOCK sensor.""" - mode: NumberMode = NumberMode("slider") rest_path: str = "" rest_arg: str = "" @@ -46,7 +45,7 @@ NUMBERS: Final = { native_min_value=0, native_max_value=100, native_step=1, - mode=NumberMode("slider"), + mode=NumberMode.SLIDER, rest_path="thermostat/0", rest_arg="pos", ), From 9e529d1d5914049ed73829b6be16f1f8523f77ef Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 4 May 2023 21:54:28 +0200 Subject: [PATCH 195/822] Refactor of unique ID and entity description handling in Rituals Perfume Genie (#92545) --- .../components/rituals_perfume_genie/binary_sensor.py | 4 +--- homeassistant/components/rituals_perfume_genie/entity.py | 5 ++++- homeassistant/components/rituals_perfume_genie/number.py | 4 +--- homeassistant/components/rituals_perfume_genie/select.py | 4 +--- homeassistant/components/rituals_perfume_genie/sensor.py | 4 +--- homeassistant/components/rituals_perfume_genie/switch.py | 4 +--- 6 files changed, 9 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/rituals_perfume_genie/binary_sensor.py b/homeassistant/components/rituals_perfume_genie/binary_sensor.py index 37d60522dea6..3c49dc14f516 100644 --- a/homeassistant/components/rituals_perfume_genie/binary_sensor.py +++ b/homeassistant/components/rituals_perfume_genie/binary_sensor.py @@ -77,9 +77,7 @@ class RitualsBinarySensorEntity(DiffuserEntity, BinarySensorEntity): description: RitualsBinarySensorEntityDescription, ) -> None: """Initialize Rituals binary sensor entity.""" - super().__init__(coordinator) - self.entity_description = description - self._attr_unique_id = f"{coordinator.diffuser.hublot}-{description.key}" + super().__init__(coordinator, description) self._attr_name = f"{coordinator.diffuser.name} {description.name}" @property diff --git a/homeassistant/components/rituals_perfume_genie/entity.py b/homeassistant/components/rituals_perfume_genie/entity.py index 07944f4eed35..e5b9f3ebd6fe 100644 --- a/homeassistant/components/rituals_perfume_genie/entity.py +++ b/homeassistant/components/rituals_perfume_genie/entity.py @@ -1,7 +1,7 @@ """Base class for Rituals Perfume Genie diffuser entity.""" from __future__ import annotations -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity import DeviceInfo, EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -18,9 +18,12 @@ class DiffuserEntity(CoordinatorEntity[RitualsDataUpdateCoordinator]): def __init__( self, coordinator: RitualsDataUpdateCoordinator, + description: EntityDescription, ) -> None: """Init from config, hookup diffuser and coordinator.""" super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.diffuser.hublot}-{description.key}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, coordinator.diffuser.hublot)}, manufacturer=MANUFACTURER, diff --git a/homeassistant/components/rituals_perfume_genie/number.py b/homeassistant/components/rituals_perfume_genie/number.py index 487fd12f3391..8049e53fa0d4 100644 --- a/homeassistant/components/rituals_perfume_genie/number.py +++ b/homeassistant/components/rituals_perfume_genie/number.py @@ -72,9 +72,7 @@ class RitualsNumberEntity(DiffuserEntity, NumberEntity): description: RitualsNumberEntityDescription, ) -> None: """Initialize the diffuser perfume amount number.""" - super().__init__(coordinator) - self.entity_description = description - self._attr_unique_id = f"{coordinator.diffuser.hublot}-{description.key}" + super().__init__(coordinator, description) self._attr_name = f"{coordinator.diffuser.name} {description.name}" @property diff --git a/homeassistant/components/rituals_perfume_genie/select.py b/homeassistant/components/rituals_perfume_genie/select.py index f721f2e6e29f..10ad5dbf3bbb 100644 --- a/homeassistant/components/rituals_perfume_genie/select.py +++ b/homeassistant/components/rituals_perfume_genie/select.py @@ -76,12 +76,10 @@ class RitualsSelectEntity(DiffuserEntity, SelectEntity): description: RitualsSelectEntityDescription, ) -> None: """Initialize the diffuser room size select entity.""" - super().__init__(coordinator) - self.entity_description = description + super().__init__(coordinator, description) self._attr_entity_registry_enabled_default = ( self.coordinator.diffuser.has_battery ) - self._attr_unique_id = f"{coordinator.diffuser.hublot}-{description.key}" self._attr_name = f"{coordinator.diffuser.name} {description.name}" @property diff --git a/homeassistant/components/rituals_perfume_genie/sensor.py b/homeassistant/components/rituals_perfume_genie/sensor.py index cd637a2284fd..e48bf4de2193 100644 --- a/homeassistant/components/rituals_perfume_genie/sensor.py +++ b/homeassistant/components/rituals_perfume_genie/sensor.py @@ -98,9 +98,7 @@ class RitualsSensorEntity(DiffuserEntity, SensorEntity): description: RitualsSensorEntityDescription, ) -> None: """Initialize the diffuser sensor.""" - super().__init__(coordinator) - self.entity_description = description - self._attr_unique_id = f"{coordinator.diffuser.hublot}-{description.key}" + super().__init__(coordinator, description) self._attr_name = f"{coordinator.diffuser.name} {description.name}" @property diff --git a/homeassistant/components/rituals_perfume_genie/switch.py b/homeassistant/components/rituals_perfume_genie/switch.py index 85e29f7f1ca7..44ff951f5326 100644 --- a/homeassistant/components/rituals_perfume_genie/switch.py +++ b/homeassistant/components/rituals_perfume_genie/switch.py @@ -72,9 +72,7 @@ class RitualsSwitchEntity(DiffuserEntity, SwitchEntity): description: RitualsSwitchEntityDescription, ) -> None: """Initialize the diffuser switch.""" - super().__init__(coordinator) - self.entity_description = description - self._attr_unique_id = f"{coordinator.diffuser.hublot}-{description.key}" + super().__init__(coordinator, description) self._attr_name = coordinator.diffuser.name self._attr_is_on = description.is_on_fn(coordinator.diffuser) From 5f22b0c6cc50b08a58d9bf1948b90c03baf50098 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Thu, 4 May 2023 20:02:17 -0400 Subject: [PATCH 196/822] Handle invalid ZHA cluster handlers (#92543) * Do not crash on startup when an invalid cluster handler is encountered * Add a unit test --- homeassistant/components/zha/core/endpoint.py | 14 ++++++- tests/components/zha/test_cluster_handlers.py | 40 +++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/zha/core/endpoint.py b/homeassistant/components/zha/core/endpoint.py index d134c033ed77..53a3fb883ef3 100644 --- a/homeassistant/components/zha/core/endpoint.py +++ b/homeassistant/components/zha/core/endpoint.py @@ -137,7 +137,19 @@ class Endpoint: ): cluster_handler_class = MultistateInput # end of ugly hack - cluster_handler = cluster_handler_class(cluster, self) + + try: + cluster_handler = cluster_handler_class(cluster, self) + except KeyError as err: + _LOGGER.warning( + "Cluster handler %s for cluster %s on endpoint %s is invalid: %s", + cluster_handler_class, + cluster, + self, + err, + ) + continue + if cluster_handler.name == const.CLUSTER_HANDLER_POWER_CONFIGURATION: self._device.power_configuration_ch = cluster_handler elif cluster_handler.name == const.CLUSTER_HANDLER_IDENTIFY: diff --git a/tests/components/zha/test_cluster_handlers.py b/tests/components/zha/test_cluster_handlers.py index c0c455542d3b..1897383b6c4f 100644 --- a/tests/components/zha/test_cluster_handlers.py +++ b/tests/components/zha/test_cluster_handlers.py @@ -1,11 +1,13 @@ """Test ZHA Core cluster handlers.""" import asyncio from collections.abc import Callable +import logging import math from unittest import mock from unittest.mock import AsyncMock, patch import pytest +import zigpy.device import zigpy.endpoint from zigpy.endpoint import Endpoint as ZigpyEndpoint import zigpy.profiles.zha @@ -791,3 +793,41 @@ async def test_configure_reporting(hass: HomeAssistant, endpoint) -> None: } ), ] + + +async def test_invalid_cluster_handler(hass: HomeAssistant, caplog) -> None: + """Test setting up a cluster handler that fails to match properly.""" + + class TestZigbeeClusterHandler(cluster_handlers.ClusterHandler): + REPORT_CONFIG = ( + cluster_handlers.AttrReportConfig(attr="missing_attr", config=(1, 60, 1)), + ) + + mock_device = mock.AsyncMock(spec_set=zigpy.device.Device) + zigpy_ep = zigpy.endpoint.Endpoint(mock_device, endpoint_id=1) + + cluster = zigpy_ep.add_input_cluster(zigpy.zcl.clusters.lighting.Color.cluster_id) + cluster.configure_reporting_multiple = AsyncMock( + spec_set=cluster.configure_reporting_multiple, + return_value=[ + foundation.ConfigureReportingResponseRecord( + status=foundation.Status.SUCCESS + ) + ], + ) + + mock_zha_device = mock.AsyncMock(spec_set=ZHADevice) + zha_endpoint = Endpoint(zigpy_ep, mock_zha_device) + + # The cluster handler throws an error when matching this cluster + with pytest.raises(KeyError): + TestZigbeeClusterHandler(cluster, zha_endpoint) + + # And one is also logged at runtime + with patch.dict( + registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY, + {cluster.cluster_id: TestZigbeeClusterHandler}, + ), caplog.at_level(logging.WARNING): + zha_endpoint.add_all_cluster_handlers() + + assert "missing_attr" in caplog.text From 785faf4380fcdaba9310524b2fde9f7315345dc7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 May 2023 21:18:20 -0500 Subject: [PATCH 197/822] Bump aioesphomeapi to 13.7.3 to fix disconnecting while handshake is in progress (#92537) Bump aioesphomeapi to 13.7.3 fixes #92432 --- 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 3576dadd1c01..ff78996f3aa6 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -15,7 +15,7 @@ "iot_class": "local_push", "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ - "aioesphomeapi==13.7.2", + "aioesphomeapi==13.7.3", "bluetooth-data-tools==0.4.0", "esphome-dashboard-api==1.2.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index c4889227f027..57ec8407d003 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -156,7 +156,7 @@ aioecowitt==2023.01.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==13.7.2 +aioesphomeapi==13.7.3 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f3cf2a64a6cc..9a10b57db900 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -146,7 +146,7 @@ aioecowitt==2023.01.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==13.7.2 +aioesphomeapi==13.7.3 # homeassistant.components.flo aioflo==2021.11.0 From 6dd88a767b951bb123ef58dc71b916f17c541558 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 May 2023 21:20:25 -0500 Subject: [PATCH 198/822] Bump elkm1-lib to 2.2.2 (#92560) changelog: https://github.com/gwww/elkm1/compare/2.2.1...2.2.2 fixes #92467 --- 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 26fab34f0e13..d7094a2e60b1 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.1"] + "requirements": ["elkm1-lib==2.2.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 57ec8407d003..0564c3368a09 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -644,7 +644,7 @@ elgato==4.0.1 eliqonline==1.2.2 # homeassistant.components.elkm1 -elkm1-lib==2.2.1 +elkm1-lib==2.2.2 # homeassistant.components.elmax elmax_api==0.0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9a10b57db900..828ef5f5f1db 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -506,7 +506,7 @@ easyenergy==0.3.0 elgato==4.0.1 # homeassistant.components.elkm1 -elkm1-lib==2.2.1 +elkm1-lib==2.2.2 # homeassistant.components.elmax elmax_api==0.0.4 From 3f630493ffb01bd43c1606eaf0355cb0ea2faf61 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 May 2023 21:21:42 -0500 Subject: [PATCH 199/822] Allow duplicate state updates when force_update is set on an esphome sensor (#92553) * Allow duplicate states when force_update is set on an esphome sensor fixes #91221 * Update homeassistant/components/esphome/entry_data.py Co-authored-by: pdw-mb --------- Co-authored-by: pdw-mb --- homeassistant/components/esphome/entry_data.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 61d6262250c0..7ce195d68fce 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -25,6 +25,7 @@ from aioesphomeapi import ( NumberInfo, SelectInfo, SensorInfo, + SensorState, SwitchInfo, TextSensorInfo, UserService, @@ -240,9 +241,18 @@ class RuntimeEntryData: current_state_by_type = self.state[state_type] current_state = current_state_by_type.get(key, _SENTINEL) subscription_key = (state_type, key) - if current_state == state and subscription_key not in stale_state: + if ( + current_state == state + and subscription_key not in stale_state + and not ( + type(state) is SensorState # pylint: disable=unidiomatic-typecheck + and (platform_info := self.info.get(Platform.SENSOR)) + and (entity_info := platform_info.get(state.key)) + and (cast(SensorInfo, entity_info)).force_update + ) + ): _LOGGER.debug( - "%s: ignoring duplicate update with and key %s: %s", + "%s: ignoring duplicate update with key %s: %s", self.name, key, state, From b6664ce367f19f27ad7fdbd537094f10f5cbedd7 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 5 May 2023 05:10:43 +0200 Subject: [PATCH 200/822] Improve cloud migration (#92520) * Improve cloud migration * Tweak * Use entity_ids func --------- Co-authored-by: Paulus Schoutsen --- homeassistant/components/alexa/config.py | 4 ++-- .../components/cloud/alexa_config.py | 12 ++++------- .../components/cloud/google_config.py | 21 ++++--------------- 3 files changed, 10 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/alexa/config.py b/homeassistant/components/alexa/config.py index cdbea2ca346f..159bfebc6241 100644 --- a/homeassistant/components/alexa/config.py +++ b/homeassistant/components/alexa/config.py @@ -3,7 +3,7 @@ from abc import ABC, abstractmethod import asyncio import logging -from homeassistant.core import CALLBACK_TYPE, callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.storage import Store from .const import DOMAIN @@ -19,7 +19,7 @@ class AbstractConfig(ABC): _unsub_proactive_report: asyncio.Task[CALLBACK_TYPE] | None = None - def __init__(self, hass): + def __init__(self, hass: HomeAssistant) -> None: """Initialize abstract config.""" self.hass = hass self._store = None diff --git a/homeassistant/components/cloud/alexa_config.py b/homeassistant/components/cloud/alexa_config.py index 4ba32c338b5c..b7f0b5f67634 100644 --- a/homeassistant/components/cloud/alexa_config.py +++ b/homeassistant/components/cloud/alexa_config.py @@ -199,14 +199,10 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): # Don't migrate if there's a YAML config return - for state in self.hass.states.async_all(): - async_expose_entity( - self.hass, - CLOUD_ALEXA, - state.entity_id, - self._should_expose_legacy(state.entity_id), - ) - for entity_id in self._prefs.alexa_entity_configs: + for entity_id in { + *self.hass.states.async_entity_ids(), + *self._prefs.alexa_entity_configs, + }: async_expose_entity( self.hass, CLOUD_ALEXA, diff --git a/homeassistant/components/cloud/google_config.py b/homeassistant/components/cloud/google_config.py index 16848acc19d0..02aa5760597a 100644 --- a/homeassistant/components/cloud/google_config.py +++ b/homeassistant/components/cloud/google_config.py @@ -175,23 +175,10 @@ class CloudGoogleConfig(AbstractConfig): # Don't migrate if there's a YAML config return - for state in self.hass.states.async_all(): - entity_id = state.entity_id - async_expose_entity( - self.hass, - CLOUD_GOOGLE, - entity_id, - self._should_expose_legacy(entity_id), - ) - if _2fa_disabled := (self._2fa_disabled_legacy(entity_id) is not None): - async_set_assistant_option( - self.hass, - CLOUD_GOOGLE, - entity_id, - PREF_DISABLE_2FA, - _2fa_disabled, - ) - for entity_id in self._prefs.google_entity_configs: + for entity_id in { + *self.hass.states.async_entity_ids(), + *self._prefs.google_entity_configs, + }: async_expose_entity( self.hass, CLOUD_GOOGLE, From 9ce062411d98dff1bc1655a944527ae37d080a81 Mon Sep 17 00:00:00 2001 From: DDanii Date: Fri, 5 May 2023 08:42:51 +0200 Subject: [PATCH 201/822] Fix transmission error handling (#91548) * transmission error handle fix * added unexpected case tests --- .../components/transmission/__init__.py | 19 +++++++----- .../transmission/test_config_flow.py | 29 +++++++++++++++---- tests/components/transmission/test_init.py | 24 +++++++++++++-- 3 files changed, 56 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/transmission/__init__.py b/homeassistant/components/transmission/__init__.py index 765755d12485..d8623e7bbe53 100644 --- a/homeassistant/components/transmission/__init__.py +++ b/homeassistant/components/transmission/__init__.py @@ -7,7 +7,11 @@ import logging from typing import Any import transmission_rpc -from transmission_rpc.error import TransmissionError +from transmission_rpc.error import ( + TransmissionAuthError, + TransmissionConnectError, + TransmissionError, +) import voluptuous as vol from homeassistant.config_entries import ConfigEntry, ConfigEntryState @@ -137,14 +141,13 @@ async def get_api(hass, entry): _LOGGER.debug("Successfully connected to %s", host) return api + except TransmissionAuthError as error: + _LOGGER.error("Credentials for Transmission client are not valid") + raise AuthenticationError from error + except TransmissionConnectError as error: + _LOGGER.error("Connecting to the Transmission client %s failed", host) + raise CannotConnect from error except TransmissionError as error: - if "401: Unauthorized" in str(error): - _LOGGER.error("Credentials for Transmission client are not valid") - raise AuthenticationError from error - if "111: Connection refused" in str(error): - _LOGGER.error("Connecting to the Transmission client %s failed", host) - raise CannotConnect from error - _LOGGER.error(error) raise UnknownError from error diff --git a/tests/components/transmission/test_config_flow.py b/tests/components/transmission/test_config_flow.py index d163708ce28a..b4fae8e6f3d6 100644 --- a/tests/components/transmission/test_config_flow.py +++ b/tests/components/transmission/test_config_flow.py @@ -2,7 +2,11 @@ from unittest.mock import MagicMock, patch import pytest -from transmission_rpc.error import TransmissionError +from transmission_rpc.error import ( + TransmissionAuthError, + TransmissionConnectError, + TransmissionError, +) from homeassistant import config_entries from homeassistant.components import transmission @@ -125,7 +129,7 @@ async def test_error_on_wrong_credentials( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - mock_api.side_effect = TransmissionError("401: Unauthorized") + mock_api.side_effect = TransmissionAuthError() result2 = await hass.config_entries.flow.async_configure( result["flow_id"], MOCK_CONFIG_DATA, @@ -137,6 +141,21 @@ async def test_error_on_wrong_credentials( } +async def test_unexpected_error(hass: HomeAssistant, mock_api: MagicMock) -> None: + """Test we handle unexpected error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + mock_api.side_effect = TransmissionError() + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_CONFIG_DATA, + ) + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + + async def test_error_on_connection_failure( hass: HomeAssistant, mock_api: MagicMock ) -> None: @@ -145,7 +164,7 @@ async def test_error_on_connection_failure( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - mock_api.side_effect = TransmissionError("111: Connection refused") + mock_api.side_effect = TransmissionConnectError() result2 = await hass.config_entries.flow.async_configure( result["flow_id"], MOCK_CONFIG_DATA, @@ -213,7 +232,7 @@ async def test_reauth_failed(hass: HomeAssistant, mock_api: MagicMock) -> None: assert result["step_id"] == "reauth_confirm" assert result["description_placeholders"] == {"username": "user"} - mock_api.side_effect = TransmissionError("401: Unauthorized") + mock_api.side_effect = TransmissionAuthError() result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -248,7 +267,7 @@ async def test_reauth_failed_connection_error( assert result["step_id"] == "reauth_confirm" assert result["description_placeholders"] == {"username": "user"} - mock_api.side_effect = TransmissionError("111: Connection refused") + mock_api.side_effect = TransmissionConnectError() result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { diff --git a/tests/components/transmission/test_init.py b/tests/components/transmission/test_init.py index da5e68595447..89ad0dd24103 100644 --- a/tests/components/transmission/test_init.py +++ b/tests/components/transmission/test_init.py @@ -3,7 +3,11 @@ from unittest.mock import MagicMock, patch import pytest -from transmission_rpc.error import TransmissionError +from transmission_rpc.error import ( + TransmissionAuthError, + TransmissionConnectError, + TransmissionError, +) from homeassistant.components.transmission.const import DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -40,7 +44,7 @@ async def test_setup_failed_connection_error( entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG_DATA) entry.add_to_hass(hass) - mock_api.side_effect = TransmissionError("111: Connection refused") + mock_api.side_effect = TransmissionConnectError() await hass.config_entries.async_setup(entry.entry_id) assert entry.state == ConfigEntryState.SETUP_RETRY @@ -54,7 +58,21 @@ async def test_setup_failed_auth_error( entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG_DATA) entry.add_to_hass(hass) - mock_api.side_effect = TransmissionError("401: Unauthorized") + mock_api.side_effect = TransmissionAuthError() + + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state == ConfigEntryState.SETUP_ERROR + + +async def test_setup_failed_unexpected_error( + hass: HomeAssistant, mock_api: MagicMock +) -> None: + """Test integration failed due to unexpected error.""" + + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG_DATA) + entry.add_to_hass(hass) + + mock_api.side_effect = TransmissionError() await hass.config_entries.async_setup(entry.entry_id) assert entry.state == ConfigEntryState.SETUP_ERROR From 603aa759d38c8211b509ab0325e0460c2a036312 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 5 May 2023 09:05:31 +0200 Subject: [PATCH 202/822] Fix KeyError in rest (#92464) --- homeassistant/components/rest/binary_sensor.py | 2 +- homeassistant/components/rest/data.py | 5 +++++ homeassistant/components/rest/sensor.py | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/rest/binary_sensor.py b/homeassistant/components/rest/binary_sensor.py index 60d9a2d85044..0c1f4df60930 100644 --- a/homeassistant/components/rest/binary_sensor.py +++ b/homeassistant/components/rest/binary_sensor.py @@ -67,7 +67,7 @@ async def async_setup_platform( if isinstance(rest.last_exception, ssl.SSLError): _LOGGER.error( "Error connecting %s failed with %s", - conf[CONF_RESOURCE], + rest.url, rest.last_exception, ) return diff --git a/homeassistant/components/rest/data.py b/homeassistant/components/rest/data.py index 8f1dd9373918..95086f68d70a 100644 --- a/homeassistant/components/rest/data.py +++ b/homeassistant/components/rest/data.py @@ -50,6 +50,11 @@ class RestData: self.last_exception: Exception | None = None self.headers: httpx.Headers | None = None + @property + def url(self) -> str: + """Get url.""" + return self._resource + def set_url(self, url: str) -> None: """Set url.""" self._resource = url diff --git a/homeassistant/components/rest/sensor.py b/homeassistant/components/rest/sensor.py index ead5a5893f42..6fc0b69d1fd2 100644 --- a/homeassistant/components/rest/sensor.py +++ b/homeassistant/components/rest/sensor.py @@ -71,7 +71,7 @@ async def async_setup_platform( if isinstance(rest.last_exception, ssl.SSLError): _LOGGER.error( "Error connecting %s failed with %s", - conf[CONF_RESOURCE], + rest.url, rest.last_exception, ) return From 1976232d5a92d4489ff090761992748298e34fca Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Fri, 5 May 2023 10:47:49 +0200 Subject: [PATCH 203/822] Fix for SIA Code not being handled well (#92469) * updated sia requirements * updates because of changes in package * linting and other small fixes * fix for unknown code * added same to alarm_control_panel --- homeassistant/components/sia/alarm_control_panel.py | 2 +- homeassistant/components/sia/binary_sensor.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sia/alarm_control_panel.py b/homeassistant/components/sia/alarm_control_panel.py index 6a86ce81445d..ef2ecc7aa23e 100644 --- a/homeassistant/components/sia/alarm_control_panel.py +++ b/homeassistant/components/sia/alarm_control_panel.py @@ -123,7 +123,7 @@ class SIAAlarmControlPanel(SIABaseEntity, AlarmControlPanelEntity): """ new_state = None if sia_event.code: - new_state = self.entity_description.code_consequences[sia_event.code] + new_state = self.entity_description.code_consequences.get(sia_event.code) if new_state is None: return False _LOGGER.debug("New state will be %s", new_state) diff --git a/homeassistant/components/sia/binary_sensor.py b/homeassistant/components/sia/binary_sensor.py index 715fa26eee98..db0845473fdb 100644 --- a/homeassistant/components/sia/binary_sensor.py +++ b/homeassistant/components/sia/binary_sensor.py @@ -132,7 +132,7 @@ class SIABinarySensor(SIABaseEntity, BinarySensorEntity): """ new_state = None if sia_event.code: - new_state = self.entity_description.code_consequences[sia_event.code] + new_state = self.entity_description.code_consequences.get(sia_event.code) if new_state is None: return False _LOGGER.debug("New state will be %s", new_state) From e55f2173f849653891ff65e3f6d1f40326b030a7 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 5 May 2023 13:29:00 +0200 Subject: [PATCH 204/822] Use selectors in Trafikverket Train (#92554) Use selectors --- .../trafikverket_train/config_flow.py | 23 ++++++++++++++----- .../trafikverket_train/strings.json | 13 +++++++++++ 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/trafikverket_train/config_flow.py b/homeassistant/components/trafikverket_train/config_flow.py index c620e264142d..55db0f982446 100644 --- a/homeassistant/components/trafikverket_train/config_flow.py +++ b/homeassistant/components/trafikverket_train/config_flow.py @@ -12,6 +12,12 @@ from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_WEEKDAY, WEEKDAYS from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, + TextSelector, +) import homeassistant.util.dt as dt_util from .const import CONF_FROM, CONF_TIME, CONF_TO, DOMAIN @@ -23,12 +29,17 @@ ERROR_MULTIPLE_STATION = "Found multiple stations with the specified name" DATA_SCHEMA = vol.Schema( { - vol.Required(CONF_API_KEY): cv.string, - vol.Required(CONF_FROM): cv.string, - vol.Required(CONF_TO): cv.string, - vol.Optional(CONF_TIME): cv.string, - vol.Required(CONF_WEEKDAY, default=WEEKDAYS): cv.multi_select( - {day: day for day in WEEKDAYS} + vol.Required(CONF_API_KEY): TextSelector(), + vol.Required(CONF_FROM): TextSelector(), + vol.Required(CONF_TO): TextSelector(), + vol.Optional(CONF_TIME): TextSelector(), + vol.Required(CONF_WEEKDAY, default=WEEKDAYS): SelectSelector( + SelectSelectorConfig( + options=WEEKDAYS, + multiple=True, + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_WEEKDAY, + ) ), } ) diff --git a/homeassistant/components/trafikverket_train/strings.json b/homeassistant/components/trafikverket_train/strings.json index 6f6ed44f7a53..6c67d881153a 100644 --- a/homeassistant/components/trafikverket_train/strings.json +++ b/homeassistant/components/trafikverket_train/strings.json @@ -28,5 +28,18 @@ } } } + }, + "selector": { + "weekday": { + "options": { + "mon": "Monday", + "tue": "Tuesday", + "wed": "Wednesday", + "thu": "Thursday", + "fri": "Friday", + "sat": "Saturday", + "sun": "Sunday" + } + } } } From 6f3c9324ac67013f9d586f072253df97a98313a9 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 5 May 2023 14:10:44 +0200 Subject: [PATCH 205/822] Fix lingering timer in hdmi_cec (#92579) --- homeassistant/components/hdmi_cec/__init__.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hdmi_cec/__init__.py b/homeassistant/components/hdmi_cec/__init__.py index 5ad8393726f0..459f03edfbbe 100644 --- a/homeassistant/components/hdmi_cec/__init__.py +++ b/homeassistant/components/hdmi_cec/__init__.py @@ -31,7 +31,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.core import HassJob, HomeAssistant, ServiceCall, callback from homeassistant.helpers import discovery, event import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -208,16 +208,18 @@ def setup(hass: HomeAssistant, base_config: ConfigType) -> bool: # noqa: C901 def _adapter_watchdog(now=None): _LOGGER.debug("Reached _adapter_watchdog") - event.call_later(hass, WATCHDOG_INTERVAL, _adapter_watchdog) + event.call_later(hass, WATCHDOG_INTERVAL, _adapter_watchdog_job) if not adapter.initialized: _LOGGER.info("Adapter not initialized; Trying to restart") hass.bus.fire(EVENT_HDMI_CEC_UNAVAILABLE) adapter.init() + _adapter_watchdog_job = HassJob(_adapter_watchdog, cancel_on_shutdown=True) + @callback def _async_initialized_callback(*_: Any): """Add watchdog on initialization.""" - return event.async_call_later(hass, WATCHDOG_INTERVAL, _adapter_watchdog) + return event.async_call_later(hass, WATCHDOG_INTERVAL, _adapter_watchdog_job) hdmi_network.set_initialized_callback(_async_initialized_callback) From 19dcc8c88f2e07e288533171d293aec60357954a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 5 May 2023 08:11:09 -0400 Subject: [PATCH 206/822] Re-run expose entities migration if first time failed (#92564) * Re-run expose entities migration if first time failed * Count number of exposed entities * Add tests --------- Co-authored-by: Erik --- .../components/cloud/alexa_config.py | 12 ++- .../components/cloud/google_config.py | 13 ++- homeassistant/components/cloud/prefs.py | 4 +- tests/components/cloud/test_alexa_config.py | 98 ++++++++++++++++++- tests/components/cloud/test_google_config.py | 98 ++++++++++++++++++- 5 files changed, 219 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/cloud/alexa_config.py b/homeassistant/components/cloud/alexa_config.py index b7f0b5f67634..53bf44d8aa12 100644 --- a/homeassistant/components/cloud/alexa_config.py +++ b/homeassistant/components/cloud/alexa_config.py @@ -216,8 +216,18 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): async def on_hass_started(hass): if self._prefs.alexa_settings_version != ALEXA_SETTINGS_VERSION: - if self._prefs.alexa_settings_version < 2: + if self._prefs.alexa_settings_version < 2 or ( + # Recover from a bug we had in 2023.5.0 where entities didn't get exposed + self._prefs.alexa_settings_version < 3 + and not any( + settings.get("should_expose", False) + for settings in async_get_assistant_settings( + hass, CLOUD_ALEXA + ).values() + ) + ): self._migrate_alexa_entity_settings_v1() + await self._prefs.async_update( alexa_settings_version=ALEXA_SETTINGS_VERSION ) diff --git a/homeassistant/components/cloud/google_config.py b/homeassistant/components/cloud/google_config.py index 02aa5760597a..351de5d0e654 100644 --- a/homeassistant/components/cloud/google_config.py +++ b/homeassistant/components/cloud/google_config.py @@ -12,6 +12,7 @@ from homeassistant.components.google_assistant import DOMAIN as GOOGLE_DOMAIN from homeassistant.components.google_assistant.helpers import AbstractConfig from homeassistant.components.homeassistant.exposed_entities import ( async_expose_entity, + async_get_assistant_settings, async_get_entity_settings, async_listen_entity_updates, async_set_assistant_option, @@ -200,8 +201,18 @@ class CloudGoogleConfig(AbstractConfig): async def on_hass_started(hass: HomeAssistant) -> None: if self._prefs.google_settings_version != GOOGLE_SETTINGS_VERSION: - if self._prefs.google_settings_version < 2: + if self._prefs.google_settings_version < 2 or ( + # Recover from a bug we had in 2023.5.0 where entities didn't get exposed + self._prefs.google_settings_version < 3 + and not any( + settings.get("should_expose", False) + for settings in async_get_assistant_settings( + hass, CLOUD_GOOGLE + ).values() + ) + ): self._migrate_google_entity_settings_v1() + await self._prefs.async_update( google_settings_version=GOOGLE_SETTINGS_VERSION ) diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index 75e1856503c0..5ccc007e5241 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -41,8 +41,8 @@ STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 STORAGE_VERSION_MINOR = 2 -ALEXA_SETTINGS_VERSION = 2 -GOOGLE_SETTINGS_VERSION = 2 +ALEXA_SETTINGS_VERSION = 3 +GOOGLE_SETTINGS_VERSION = 3 class CloudPreferencesStore(Store): diff --git a/tests/components/cloud/test_alexa_config.py b/tests/components/cloud/test_alexa_config.py index 3a7e5a0874ec..2be2a8eb2bb0 100644 --- a/tests/components/cloud/test_alexa_config.py +++ b/tests/components/cloud/test_alexa_config.py @@ -542,11 +542,13 @@ async def test_alexa_handle_logout( assert len(mock_enable.return_value.mock_calls) == 1 +@pytest.mark.parametrize("alexa_settings_version", [1, 2]) async def test_alexa_config_migrate_expose_entity_prefs( hass: HomeAssistant, cloud_prefs: CloudPreferences, cloud_stub, entity_registry: er.EntityRegistry, + alexa_settings_version: int, ) -> None: """Test migrating Alexa entity config.""" hass.state = CoreState.starting @@ -593,7 +595,7 @@ async def test_alexa_config_migrate_expose_entity_prefs( await cloud_prefs.async_update( alexa_enabled=True, alexa_report_state=False, - alexa_settings_version=1, + alexa_settings_version=alexa_settings_version, ) expose_entity(hass, entity_migrated.entity_id, False) @@ -641,6 +643,100 @@ async def test_alexa_config_migrate_expose_entity_prefs( } +async def test_alexa_config_migrate_expose_entity_prefs_v2_no_exposed( + hass: HomeAssistant, + cloud_prefs: CloudPreferences, + entity_registry: er.EntityRegistry, +) -> None: + """Test migrating Alexa entity config from v2 to v3 when no entity is exposed.""" + hass.state = CoreState.starting + + assert await async_setup_component(hass, "homeassistant", {}) + hass.states.async_set("light.state_only", "on") + entity_migrated = entity_registry.async_get_or_create( + "light", + "test", + "light_migrated", + suggested_object_id="migrated", + ) + await cloud_prefs.async_update( + alexa_enabled=True, + alexa_report_state=False, + alexa_settings_version=2, + ) + expose_entity(hass, "light.state_only", False) + expose_entity(hass, entity_migrated.entity_id, False) + + cloud_prefs._prefs[PREF_ALEXA_ENTITY_CONFIGS]["light.state_only"] = { + PREF_SHOULD_EXPOSE: True + } + cloud_prefs._prefs[PREF_ALEXA_ENTITY_CONFIGS][entity_migrated.entity_id] = { + PREF_SHOULD_EXPOSE: True + } + conf = alexa_config.CloudAlexaConfig( + hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, Mock(is_logged_in=False) + ) + await conf.async_initialize() + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + assert async_get_entity_settings(hass, "light.state_only") == { + "cloud.alexa": {"should_expose": True} + } + assert async_get_entity_settings(hass, entity_migrated.entity_id) == { + "cloud.alexa": {"should_expose": True} + } + + +async def test_alexa_config_migrate_expose_entity_prefs_v2_exposed( + hass: HomeAssistant, + cloud_prefs: CloudPreferences, + entity_registry: er.EntityRegistry, +) -> None: + """Test migrating Alexa entity config from v2 to v3 when an entity is exposed.""" + hass.state = CoreState.starting + + assert await async_setup_component(hass, "homeassistant", {}) + hass.states.async_set("light.state_only", "on") + entity_migrated = entity_registry.async_get_or_create( + "light", + "test", + "light_migrated", + suggested_object_id="migrated", + ) + await cloud_prefs.async_update( + alexa_enabled=True, + alexa_report_state=False, + alexa_settings_version=2, + ) + expose_entity(hass, "light.state_only", False) + expose_entity(hass, entity_migrated.entity_id, True) + + cloud_prefs._prefs[PREF_ALEXA_ENTITY_CONFIGS]["light.state_only"] = { + PREF_SHOULD_EXPOSE: True + } + cloud_prefs._prefs[PREF_ALEXA_ENTITY_CONFIGS][entity_migrated.entity_id] = { + PREF_SHOULD_EXPOSE: True + } + conf = alexa_config.CloudAlexaConfig( + hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, Mock(is_logged_in=False) + ) + await conf.async_initialize() + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + assert async_get_entity_settings(hass, "light.state_only") == { + "cloud.alexa": {"should_expose": False} + } + assert async_get_entity_settings(hass, entity_migrated.entity_id) == { + "cloud.alexa": {"should_expose": True} + } + + async def test_alexa_config_migrate_expose_entity_prefs_default_none( hass: HomeAssistant, cloud_prefs: CloudPreferences, diff --git a/tests/components/cloud/test_google_config.py b/tests/components/cloud/test_google_config.py index 45bc56a1700a..fe60ca971a19 100644 --- a/tests/components/cloud/test_google_config.py +++ b/tests/components/cloud/test_google_config.py @@ -483,10 +483,12 @@ async def test_google_handle_logout( assert len(mock_enable.return_value.mock_calls) == 1 +@pytest.mark.parametrize("google_settings_version", [1, 2]) async def test_google_config_migrate_expose_entity_prefs( hass: HomeAssistant, cloud_prefs: CloudPreferences, entity_registry: er.EntityRegistry, + google_settings_version: int, ) -> None: """Test migrating Google entity config.""" hass.state = CoreState.starting @@ -540,7 +542,7 @@ async def test_google_config_migrate_expose_entity_prefs( await cloud_prefs.async_update( google_enabled=True, google_report_state=False, - google_settings_version=1, + google_settings_version=google_settings_version, ) expose_entity(hass, entity_migrated.entity_id, False) @@ -596,6 +598,100 @@ async def test_google_config_migrate_expose_entity_prefs( } +async def test_google_config_migrate_expose_entity_prefs_v2_no_exposed( + hass: HomeAssistant, + cloud_prefs: CloudPreferences, + entity_registry: er.EntityRegistry, +) -> None: + """Test migrating Google entity config from v2 to v3 when no entity is exposed.""" + hass.state = CoreState.starting + + assert await async_setup_component(hass, "homeassistant", {}) + hass.states.async_set("light.state_only", "on") + entity_migrated = entity_registry.async_get_or_create( + "light", + "test", + "light_migrated", + suggested_object_id="migrated", + ) + await cloud_prefs.async_update( + google_enabled=True, + google_report_state=False, + google_settings_version=2, + ) + expose_entity(hass, "light.state_only", False) + expose_entity(hass, entity_migrated.entity_id, False) + + cloud_prefs._prefs[PREF_GOOGLE_ENTITY_CONFIGS]["light.state_only"] = { + PREF_SHOULD_EXPOSE: True + } + cloud_prefs._prefs[PREF_GOOGLE_ENTITY_CONFIGS][entity_migrated.entity_id] = { + PREF_SHOULD_EXPOSE: True + } + conf = CloudGoogleConfig( + hass, GACTIONS_SCHEMA({}), "mock-user-id", cloud_prefs, Mock(is_logged_in=False) + ) + await conf.async_initialize() + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + assert async_get_entity_settings(hass, "light.state_only") == { + "cloud.google_assistant": {"should_expose": True} + } + assert async_get_entity_settings(hass, entity_migrated.entity_id) == { + "cloud.google_assistant": {"should_expose": True} + } + + +async def test_google_config_migrate_expose_entity_prefs_v2_exposed( + hass: HomeAssistant, + cloud_prefs: CloudPreferences, + entity_registry: er.EntityRegistry, +) -> None: + """Test migrating Google entity config from v2 to v3 when an entity is exposed.""" + hass.state = CoreState.starting + + assert await async_setup_component(hass, "homeassistant", {}) + hass.states.async_set("light.state_only", "on") + entity_migrated = entity_registry.async_get_or_create( + "light", + "test", + "light_migrated", + suggested_object_id="migrated", + ) + await cloud_prefs.async_update( + google_enabled=True, + google_report_state=False, + google_settings_version=2, + ) + expose_entity(hass, "light.state_only", False) + expose_entity(hass, entity_migrated.entity_id, True) + + cloud_prefs._prefs[PREF_GOOGLE_ENTITY_CONFIGS]["light.state_only"] = { + PREF_SHOULD_EXPOSE: True + } + cloud_prefs._prefs[PREF_GOOGLE_ENTITY_CONFIGS][entity_migrated.entity_id] = { + PREF_SHOULD_EXPOSE: True + } + conf = CloudGoogleConfig( + hass, GACTIONS_SCHEMA({}), "mock-user-id", cloud_prefs, Mock(is_logged_in=False) + ) + await conf.async_initialize() + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + assert async_get_entity_settings(hass, "light.state_only") == { + "cloud.google_assistant": {"should_expose": False} + } + assert async_get_entity_settings(hass, entity_migrated.entity_id) == { + "cloud.google_assistant": {"should_expose": True} + } + + async def test_google_config_migrate_expose_entity_prefs_default_none( hass: HomeAssistant, cloud_prefs: CloudPreferences, From 176820d665bed7bf4911abb8a6a43b7f66dac689 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 5 May 2023 14:11:31 +0200 Subject: [PATCH 207/822] Fix lingering timer in freedns (#92572) * Fix lingering timer in freedns * type hint --- homeassistant/components/freedns/__init__.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/freedns/__init__.py b/homeassistant/components/freedns/__init__.py index a5c507c3857b..e6ac11889bc4 100644 --- a/homeassistant/components/freedns/__init__.py +++ b/homeassistant/components/freedns/__init__.py @@ -1,6 +1,6 @@ """Integrate with FreeDNS Dynamic DNS service at freedns.afraid.org.""" import asyncio -from datetime import timedelta +from datetime import datetime, timedelta import logging import aiohttp @@ -53,11 +53,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if result is False: return False - async def update_domain_callback(now): + async def update_domain_callback(now: datetime) -> None: """Update the FreeDNS entry.""" await _update_freedns(hass, session, url, auth_token) - async_track_time_interval(hass, update_domain_callback, update_interval) + async_track_time_interval( + hass, update_domain_callback, update_interval, cancel_on_shutdown=True + ) return True From 5843c1fa3bbbbab49f8be53e406e02541f4c2f19 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 5 May 2023 14:40:30 +0200 Subject: [PATCH 208/822] Bump hatasmota to 0.6.5 (#92585) * Bump hatasmota to 0.6.5 * Fix tests --- .../components/tasmota/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/tasmota/test_sensor.py | 102 ++++++++++++++---- 4 files changed, 82 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/tasmota/manifest.json b/homeassistant/components/tasmota/manifest.json index c9c135fcccb8..a5a8ed2f0d23 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.6.4"] + "requirements": ["hatasmota==0.6.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0564c3368a09..e7f62aa185da 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -881,7 +881,7 @@ hass_splunk==0.1.1 hassil==1.0.6 # homeassistant.components.tasmota -hatasmota==0.6.4 +hatasmota==0.6.5 # homeassistant.components.jewish_calendar hdate==0.10.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 828ef5f5f1db..10e0e53bd762 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -679,7 +679,7 @@ hass-nabucasa==0.66.2 hassil==1.0.6 # homeassistant.components.tasmota -hatasmota==0.6.4 +hatasmota==0.6.5 # homeassistant.components.jewish_calendar hdate==0.10.4 diff --git a/tests/components/tasmota/test_sensor.py b/tests/components/tasmota/test_sensor.py index 7eee8fcbe7cb..1d9334a2657a 100644 --- a/tests/components/tasmota/test_sensor.py +++ b/tests/components/tasmota/test_sensor.py @@ -102,7 +102,7 @@ INDEXED_SENSOR_CONFIG_2 = { } -NESTED_SENSOR_CONFIG = { +NESTED_SENSOR_CONFIG_1 = { "sn": { "Time": "2020-03-03T00:00:00+00:00", "TX23": { @@ -119,6 +119,17 @@ NESTED_SENSOR_CONFIG = { } } +NESTED_SENSOR_CONFIG_2 = { + "sn": { + "Time": "2023-01-27T11:04:56", + "DS18B20": { + "Id": "01191ED79190", + "Temperature": 2.4, + }, + "TempUnit": "C", + } +} + async def test_controlling_state_via_mqtt( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota @@ -174,12 +185,59 @@ async def test_controlling_state_via_mqtt( assert state.state == "20.0" +@pytest.mark.parametrize( + ("sensor_config", "entity_ids", "messages", "states"), + [ + ( + NESTED_SENSOR_CONFIG_1, + ["sensor.tasmota_tx23_speed_act", "sensor.tasmota_tx23_dir_card"], + ( + '{"TX23":{"Speed":{"Act":"12.3"},"Dir": {"Card": "WSW"}}}', + '{"StatusSNS":{"TX23":{"Speed":{"Act":"23.4"},"Dir": {"Card": "ESE"}}}}', + ), + ( + { + "sensor.tasmota_tx23_speed_act": "12.3", + "sensor.tasmota_tx23_dir_card": "WSW", + }, + { + "sensor.tasmota_tx23_speed_act": "23.4", + "sensor.tasmota_tx23_dir_card": "ESE", + }, + ), + ), + ( + NESTED_SENSOR_CONFIG_2, + ["sensor.tasmota_ds18b20_temperature", "sensor.tasmota_ds18b20_id"], + ( + '{"DS18B20":{"Id": "01191ED79190","Temperature": 12.3}}', + '{"StatusSNS":{"DS18B20":{"Id": "meep","Temperature": 23.4}}}', + ), + ( + { + "sensor.tasmota_ds18b20_temperature": "12.3", + "sensor.tasmota_ds18b20_id": "01191ED79190", + }, + { + "sensor.tasmota_ds18b20_temperature": "23.4", + "sensor.tasmota_ds18b20_id": "meep", + }, + ), + ), + ], +) async def test_nested_sensor_state_via_mqtt( - hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota + hass: HomeAssistant, + mqtt_mock: MqttMockHAClient, + setup_tasmota, + sensor_config, + entity_ids, + messages, + states, ) -> None: """Test state update via MQTT.""" config = copy.deepcopy(DEFAULT_CONFIG) - sensor_config = copy.deepcopy(NESTED_SENSOR_CONFIG) + sensor_config = copy.deepcopy(sensor_config) mac = config["mac"] async_fire_mqtt_message( @@ -195,31 +253,29 @@ async def test_nested_sensor_state_via_mqtt( ) await hass.async_block_till_done() - state = hass.states.get("sensor.tasmota_tx23_speed_act") - assert state.state == "unavailable" - assert not state.attributes.get(ATTR_ASSUMED_STATE) + for entity_id in entity_ids: + state = hass.states.get(entity_id) + assert state.state == "unavailable" + assert not state.attributes.get(ATTR_ASSUMED_STATE) async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") await hass.async_block_till_done() - state = hass.states.get("sensor.tasmota_tx23_speed_act") - assert state.state == STATE_UNKNOWN - assert not state.attributes.get(ATTR_ASSUMED_STATE) + for entity_id in entity_ids: + state = hass.states.get(entity_id) + assert state.state == STATE_UNKNOWN + assert not state.attributes.get(ATTR_ASSUMED_STATE) # Test periodic state update - async_fire_mqtt_message( - hass, "tasmota_49A3BC/tele/SENSOR", '{"TX23":{"Speed":{"Act":"12.3"}}}' - ) - state = hass.states.get("sensor.tasmota_tx23_speed_act") - assert state.state == "12.3" + async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/SENSOR", messages[0]) + for entity_id in entity_ids: + state = hass.states.get(entity_id) + assert state.state == states[0][entity_id] # Test polled state update - async_fire_mqtt_message( - hass, - "tasmota_49A3BC/stat/STATUS10", - '{"StatusSNS":{"TX23":{"Speed":{"Act":"23.4"}}}}', - ) - state = hass.states.get("sensor.tasmota_tx23_speed_act") - assert state.state == "23.4" + async_fire_mqtt_message(hass, "tasmota_49A3BC/stat/STATUS10", messages[1]) + for entity_id in entity_ids: + state = hass.states.get(entity_id) + assert state.state == states[1][entity_id] async def test_indexed_sensor_state_via_mqtt( @@ -728,7 +784,7 @@ async def test_nested_sensor_attributes( ) -> None: """Test correct attributes for sensors.""" config = copy.deepcopy(DEFAULT_CONFIG) - sensor_config = copy.deepcopy(NESTED_SENSOR_CONFIG) + sensor_config = copy.deepcopy(NESTED_SENSOR_CONFIG_1) mac = config["mac"] async_fire_mqtt_message( @@ -754,7 +810,7 @@ async def test_nested_sensor_attributes( assert state.attributes.get("device_class") is None assert state.attributes.get("friendly_name") == "Tasmota TX23 Dir Avg" assert state.attributes.get("icon") is None - assert state.attributes.get("unit_of_measurement") == " " + assert state.attributes.get("unit_of_measurement") is None async def test_indexed_sensor_attributes( From 802e907a35b1822d33e7420b4748f13319cefb1e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 5 May 2023 14:43:39 +0200 Subject: [PATCH 209/822] Migrate rest switch to httpx (#90768) --- homeassistant/components/rest/switch.py | 36 ++-- tests/components/rest/test_switch.py | 212 ++++++++++++------------ 2 files changed, 120 insertions(+), 128 deletions(-) diff --git a/homeassistant/components/rest/switch.py b/homeassistant/components/rest/switch.py index 9e016db0376a..89b6529d4838 100644 --- a/homeassistant/components/rest/switch.py +++ b/homeassistant/components/rest/switch.py @@ -6,8 +6,8 @@ from http import HTTPStatus import logging from typing import Any -import aiohttp import async_timeout +import httpx import voluptuous as vol from homeassistant.components.switch import ( @@ -30,8 +30,8 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import config_validation as cv, template -from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.template_entity import ( TEMPLATE_ENTITY_BASE_SCHEMA, TemplateEntity, @@ -89,8 +89,8 @@ async def async_setup_platform( switch = RestSwitch(hass, config, unique_id) req = await switch.get_device_state(hass) - if req.status >= HTTPStatus.BAD_REQUEST: - _LOGGER.error("Got non-ok response from resource: %s", req.status) + if req.status_code >= HTTPStatus.BAD_REQUEST: + _LOGGER.error("Got non-ok response from resource: %s", req.status_code) else: async_add_entities([switch]) except (TypeError, ValueError): @@ -98,7 +98,7 @@ async def async_setup_platform( "Missing resource or schema in configuration. " "Add http:// or https:// to your URL" ) - except (asyncio.TimeoutError, aiohttp.ClientError) as exc: + except (asyncio.TimeoutError, httpx.RequestError) as exc: raise PlatformNotReady(f"No route to resource/endpoint: {resource}") from exc @@ -120,11 +120,11 @@ class RestSwitch(TemplateEntity, SwitchEntity): unique_id=unique_id, ) - auth: aiohttp.BasicAuth | None = None + auth: httpx.BasicAuth | None = None username: str | None = None if username := config.get(CONF_USERNAME): password: str = config[CONF_PASSWORD] - auth = aiohttp.BasicAuth(username, password=password) + auth = httpx.BasicAuth(username, password=password) self._resource: str = config[CONF_RESOURCE] self._state_resource: str = config.get(CONF_STATE_RESOURCE) or self._resource @@ -155,13 +155,13 @@ class RestSwitch(TemplateEntity, SwitchEntity): try: req = await self.set_device_state(body_on_t) - if req.status == HTTPStatus.OK: + if req.status_code == HTTPStatus.OK: self._attr_is_on = True else: _LOGGER.error( "Can't turn on %s. Is resource/endpoint offline?", self._resource ) - except (asyncio.TimeoutError, aiohttp.ClientError): + except (asyncio.TimeoutError, httpx.RequestError): _LOGGER.error("Error while switching on %s", self._resource) async def async_turn_off(self, **kwargs: Any) -> None: @@ -170,24 +170,24 @@ class RestSwitch(TemplateEntity, SwitchEntity): try: req = await self.set_device_state(body_off_t) - if req.status == HTTPStatus.OK: + if req.status_code == HTTPStatus.OK: self._attr_is_on = False else: _LOGGER.error( "Can't turn off %s. Is resource/endpoint offline?", self._resource ) - except (asyncio.TimeoutError, aiohttp.ClientError): + except (asyncio.TimeoutError, httpx.RequestError): _LOGGER.error("Error while switching off %s", self._resource) - async def set_device_state(self, body: Any) -> aiohttp.ClientResponse: + async def set_device_state(self, body: Any) -> httpx.Response: """Send a state update to the device.""" - websession = async_get_clientsession(self.hass, self._verify_ssl) + websession = get_async_client(self.hass, self._verify_ssl) rendered_headers = template.render_complex(self._headers, parse_result=False) rendered_params = template.render_complex(self._params) async with async_timeout.timeout(self._timeout): - req: aiohttp.ClientResponse = await getattr(websession, self._method)( + req: httpx.Response = await getattr(websession, self._method)( self._resource, auth=self._auth, data=bytes(body, "utf-8"), @@ -202,12 +202,12 @@ class RestSwitch(TemplateEntity, SwitchEntity): await self.get_device_state(self.hass) except asyncio.TimeoutError: _LOGGER.exception("Timed out while fetching data") - except aiohttp.ClientError as err: + except httpx.RequestError as err: _LOGGER.exception("Error while fetching data: %s", err) - async def get_device_state(self, hass: HomeAssistant) -> aiohttp.ClientResponse: + async def get_device_state(self, hass: HomeAssistant) -> httpx.Response: """Get the latest data from REST API and update the state.""" - websession = async_get_clientsession(hass, self._verify_ssl) + websession = get_async_client(hass, self._verify_ssl) rendered_headers = template.render_complex(self._headers, parse_result=False) rendered_params = template.render_complex(self._params) @@ -219,7 +219,7 @@ class RestSwitch(TemplateEntity, SwitchEntity): headers=rendered_headers, params=rendered_params, ) - text = await req.text() + text = req.text if self._is_on_template is not None: text = self._is_on_template.async_render_with_possible_json_value( diff --git a/tests/components/rest/test_switch.py b/tests/components/rest/test_switch.py index 655f172833bb..5584fce5e3a0 100644 --- a/tests/components/rest/test_switch.py +++ b/tests/components/rest/test_switch.py @@ -2,8 +2,9 @@ import asyncio from http import HTTPStatus -import aiohttp +import httpx import pytest +import respx from homeassistant.components.rest import DOMAIN from homeassistant.components.rest.switch import ( @@ -45,7 +46,6 @@ from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow from tests.common import assert_setup_component, async_fire_time_changed -from tests.test_util.aiohttp import AiohttpClientMocker NAME = "foo" DEVICE_CLASS = SwitchDeviceClass.SWITCH @@ -75,13 +75,13 @@ async def test_setup_missing_schema( assert "Invalid config for [switch.rest]: invalid url" in caplog.text +@respx.mock async def test_setup_failed_connect( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, caplog: pytest.LogCaptureFixture, ) -> None: """Test setup when connection error occurs.""" - aioclient_mock.get(RESOURCE, exc=aiohttp.ClientError) + respx.get(RESOURCE).mock(side_effect=asyncio.TimeoutError()) config = {SWITCH_DOMAIN: {CONF_PLATFORM: DOMAIN, CONF_RESOURCE: RESOURCE}} assert await async_setup_component(hass, SWITCH_DOMAIN, config) await hass.async_block_till_done() @@ -89,13 +89,13 @@ async def test_setup_failed_connect( assert "No route to resource/endpoint" in caplog.text +@respx.mock async def test_setup_timeout( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, caplog: pytest.LogCaptureFixture, ) -> None: """Test setup when connection timeout occurs.""" - aioclient_mock.get(RESOURCE, exc=asyncio.TimeoutError()) + respx.get(RESOURCE).mock(side_effect=asyncio.TimeoutError()) config = {SWITCH_DOMAIN: {CONF_PLATFORM: DOMAIN, CONF_RESOURCE: RESOURCE}} assert await async_setup_component(hass, SWITCH_DOMAIN, config) await hass.async_block_till_done() @@ -103,23 +103,21 @@ async def test_setup_timeout( assert "No route to resource/endpoint" in caplog.text -async def test_setup_minimum( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +@respx.mock +async def test_setup_minimum(hass: HomeAssistant) -> None: """Test setup with minimum configuration.""" - aioclient_mock.get(RESOURCE, status=HTTPStatus.OK) + route = respx.get(RESOURCE) % HTTPStatus.OK config = {SWITCH_DOMAIN: {CONF_PLATFORM: DOMAIN, CONF_RESOURCE: RESOURCE}} with assert_setup_component(1, SWITCH_DOMAIN): assert await async_setup_component(hass, SWITCH_DOMAIN, config) await hass.async_block_till_done() - assert aioclient_mock.call_count == 1 + assert route.call_count == 1 -async def test_setup_query_params( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +@respx.mock +async def test_setup_query_params(hass: HomeAssistant) -> None: """Test setup with query params.""" - aioclient_mock.get("http://localhost/?search=something", status=HTTPStatus.OK) + route = respx.get("http://localhost/?search=something") % HTTPStatus.OK config = { SWITCH_DOMAIN: { CONF_PLATFORM: DOMAIN, @@ -131,12 +129,13 @@ async def test_setup_query_params( assert await async_setup_component(hass, SWITCH_DOMAIN, config) await hass.async_block_till_done() - assert aioclient_mock.call_count == 1 + assert route.call_count == 1 -async def test_setup(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None: +@respx.mock +async def test_setup(hass: HomeAssistant) -> None: """Test setup with valid configuration.""" - aioclient_mock.get(RESOURCE, status=HTTPStatus.OK) + route = respx.get(RESOURCE) % HTTPStatus.OK config = { SWITCH_DOMAIN: { CONF_PLATFORM: DOMAIN, @@ -149,16 +148,15 @@ async def test_setup(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) - } assert await async_setup_component(hass, SWITCH_DOMAIN, config) await hass.async_block_till_done() - assert aioclient_mock.call_count == 1 + assert route.call_count == 1 assert_setup_component(1, SWITCH_DOMAIN) -async def test_setup_with_state_resource( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +@respx.mock +async def test_setup_with_state_resource(hass: HomeAssistant) -> None: """Test setup with valid configuration.""" - aioclient_mock.get(RESOURCE, status=HTTPStatus.NOT_FOUND) - aioclient_mock.get("http://localhost/state", status=HTTPStatus.OK) + respx.get(RESOURCE) % HTTPStatus.NOT_FOUND + route = respx.get("http://localhost/state") % HTTPStatus.OK config = { SWITCH_DOMAIN: { CONF_PLATFORM: DOMAIN, @@ -172,15 +170,14 @@ async def test_setup_with_state_resource( } assert await async_setup_component(hass, SWITCH_DOMAIN, config) await hass.async_block_till_done() - assert aioclient_mock.call_count == 1 + assert route.call_count == 1 assert_setup_component(1, SWITCH_DOMAIN) -async def test_setup_with_templated_headers_params( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +@respx.mock +async def test_setup_with_templated_headers_params(hass: HomeAssistant) -> None: """Test setup with valid configuration.""" - aioclient_mock.get(RESOURCE, status=HTTPStatus.OK) + route = respx.get(RESOURCE) % HTTPStatus.OK config = { SWITCH_DOMAIN: { CONF_PLATFORM: DOMAIN, @@ -198,21 +195,21 @@ async def test_setup_with_templated_headers_params( } assert await async_setup_component(hass, SWITCH_DOMAIN, config) await hass.async_block_till_done() - assert aioclient_mock.call_count == 1 - assert aioclient_mock.mock_calls[-1][3].get("Accept") == CONTENT_TYPE_JSON - assert aioclient_mock.mock_calls[-1][3].get("User-Agent") == "Mozilla/5.0" - assert aioclient_mock.mock_calls[-1][1].query["start"] == "0" - assert aioclient_mock.mock_calls[-1][1].query["end"] == "5" + assert route.call_count == 1 + last_call = route.calls[-1] + last_request: httpx.Request = last_call.request + assert last_request.headers.get("Accept") == CONTENT_TYPE_JSON + assert last_request.headers.get("User-Agent") == "Mozilla/5.0" + assert last_request.url.params["start"] == "0" + assert last_request.url.params["end"] == "5" assert_setup_component(1, SWITCH_DOMAIN) # Tests for REST switch platform. -async def _async_setup_test_switch( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: - aioclient_mock.get(RESOURCE, status=HTTPStatus.OK) +async def _async_setup_test_switch(hass: HomeAssistant) -> None: + respx.get(RESOURCE) % HTTPStatus.OK headers = {"Content-type": CONTENT_TYPE_JSON} config = { @@ -223,51 +220,48 @@ async def _async_setup_test_switch( CONF_STATE_RESOURCE: STATE_RESOURCE, CONF_HEADERS: headers, } - assert await async_setup_component(hass, SWITCH_DOMAIN, {SWITCH_DOMAIN: config}) await hass.async_block_till_done() assert_setup_component(1, SWITCH_DOMAIN) assert hass.states.get("switch.foo").state == STATE_UNKNOWN - aioclient_mock.clear_requests() + respx.reset() -async def test_name(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None: +@respx.mock +async def test_name(hass: HomeAssistant) -> None: """Test the name.""" - await _async_setup_test_switch(hass, aioclient_mock) + await _async_setup_test_switch(hass) state = hass.states.get("switch.foo") assert state.attributes[ATTR_FRIENDLY_NAME] == NAME -async def test_device_class( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +@respx.mock +async def test_device_class(hass: HomeAssistant) -> None: """Test the device class.""" - await _async_setup_test_switch(hass, aioclient_mock) + await _async_setup_test_switch(hass) state = hass.states.get("switch.foo") assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS -async def test_is_on_before_update( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +@respx.mock +async def test_is_on_before_update(hass: HomeAssistant) -> None: """Test is_on in initial state.""" - await _async_setup_test_switch(hass, aioclient_mock) + await _async_setup_test_switch(hass) state = hass.states.get("switch.foo") assert state.state == STATE_UNKNOWN -async def test_turn_on_success( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +@respx.mock +async def test_turn_on_success(hass: HomeAssistant) -> None: """Test turn_on.""" - await _async_setup_test_switch(hass, aioclient_mock) + await _async_setup_test_switch(hass) - aioclient_mock.post(RESOURCE, status=HTTPStatus.OK) - aioclient_mock.get(RESOURCE, exc=aiohttp.ClientError) + route = respx.post(RESOURCE) % HTTPStatus.OK + respx.get(RESOURCE).mock(side_effect=httpx.RequestError) assert await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, @@ -276,17 +270,18 @@ async def test_turn_on_success( ) await hass.async_block_till_done() - assert aioclient_mock.mock_calls[-2][2].decode() == "ON" + last_call = route.calls[-1] + last_request: httpx.Request = last_call.request + assert last_request.content.decode() == "ON" assert hass.states.get("switch.foo").state == STATE_ON -async def test_turn_on_status_not_ok( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +@respx.mock +async def test_turn_on_status_not_ok(hass: HomeAssistant) -> None: """Test turn_on when error status returned.""" - await _async_setup_test_switch(hass, aioclient_mock) + await _async_setup_test_switch(hass) - aioclient_mock.post(RESOURCE, status=HTTPStatus.INTERNAL_SERVER_ERROR) + route = respx.post(RESOURCE) % HTTPStatus.INTERNAL_SERVER_ERROR assert await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, @@ -295,17 +290,18 @@ async def test_turn_on_status_not_ok( ) await hass.async_block_till_done() - assert aioclient_mock.mock_calls[-1][2].decode() == "ON" + last_call = route.calls[-1] + last_request: httpx.Request = last_call.request + assert last_request.content.decode() == "ON" assert hass.states.get("switch.foo").state == STATE_UNKNOWN -async def test_turn_on_timeout( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +@respx.mock +async def test_turn_on_timeout(hass: HomeAssistant) -> None: """Test turn_on when timeout occurs.""" - await _async_setup_test_switch(hass, aioclient_mock) + await _async_setup_test_switch(hass) - aioclient_mock.post(RESOURCE, status=HTTPStatus.INTERNAL_SERVER_ERROR) + respx.post(RESOURCE) % HTTPStatus.INTERNAL_SERVER_ERROR assert await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, @@ -317,14 +313,13 @@ async def test_turn_on_timeout( assert hass.states.get("switch.foo").state == STATE_UNKNOWN -async def test_turn_off_success( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +@respx.mock +async def test_turn_off_success(hass: HomeAssistant) -> None: """Test turn_off.""" - await _async_setup_test_switch(hass, aioclient_mock) + await _async_setup_test_switch(hass) - aioclient_mock.post(RESOURCE, status=HTTPStatus.OK) - aioclient_mock.get(RESOURCE, exc=aiohttp.ClientError) + route = respx.post(RESOURCE) % HTTPStatus.OK + respx.get(RESOURCE).mock(side_effect=httpx.RequestError) assert await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, @@ -333,18 +328,19 @@ async def test_turn_off_success( ) await hass.async_block_till_done() - assert aioclient_mock.mock_calls[-2][2].decode() == "OFF" + last_call = route.calls[-1] + last_request: httpx.Request = last_call.request + assert last_request.content.decode() == "OFF" assert hass.states.get("switch.foo").state == STATE_OFF -async def test_turn_off_status_not_ok( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +@respx.mock +async def test_turn_off_status_not_ok(hass: HomeAssistant) -> None: """Test turn_off when error status returned.""" - await _async_setup_test_switch(hass, aioclient_mock) + await _async_setup_test_switch(hass) - aioclient_mock.post(RESOURCE, status=HTTPStatus.INTERNAL_SERVER_ERROR) + route = respx.post(RESOURCE) % HTTPStatus.INTERNAL_SERVER_ERROR assert await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, @@ -353,18 +349,19 @@ async def test_turn_off_status_not_ok( ) await hass.async_block_till_done() - assert aioclient_mock.mock_calls[-1][2].decode() == "OFF" + last_call = route.calls[-1] + last_request: httpx.Request = last_call.request + assert last_request.content.decode() == "OFF" assert hass.states.get("switch.foo").state == STATE_UNKNOWN -async def test_turn_off_timeout( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +@respx.mock +async def test_turn_off_timeout(hass: HomeAssistant) -> None: """Test turn_off when timeout occurs.""" - await _async_setup_test_switch(hass, aioclient_mock) + await _async_setup_test_switch(hass) - aioclient_mock.post(RESOURCE, exc=asyncio.TimeoutError()) + respx.post(RESOURCE).mock(side_effect=asyncio.TimeoutError()) assert await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, @@ -376,64 +373,59 @@ async def test_turn_off_timeout( assert hass.states.get("switch.foo").state == STATE_UNKNOWN -async def test_update_when_on( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +@respx.mock +async def test_update_when_on(hass: HomeAssistant) -> None: """Test update when switch is on.""" - await _async_setup_test_switch(hass, aioclient_mock) + await _async_setup_test_switch(hass) - aioclient_mock.get(RESOURCE, text="ON") + respx.get(RESOURCE).respond(text="ON") async_fire_time_changed(hass, utcnow() + SCAN_INTERVAL) await hass.async_block_till_done() assert hass.states.get("switch.foo").state == STATE_ON -async def test_update_when_off( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +@respx.mock +async def test_update_when_off(hass: HomeAssistant) -> None: """Test update when switch is off.""" - await _async_setup_test_switch(hass, aioclient_mock) + await _async_setup_test_switch(hass) - aioclient_mock.get(RESOURCE, text="OFF") + respx.get(RESOURCE).respond(text="OFF") async_fire_time_changed(hass, utcnow() + SCAN_INTERVAL) await hass.async_block_till_done() assert hass.states.get("switch.foo").state == STATE_OFF -async def test_update_when_unknown( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +@respx.mock +async def test_update_when_unknown(hass: HomeAssistant) -> None: """Test update when unknown status returned.""" - await _async_setup_test_switch(hass, aioclient_mock) + await _async_setup_test_switch(hass) - aioclient_mock.get(RESOURCE, text="unknown status") + respx.get(RESOURCE).respond(text="unknown status") async_fire_time_changed(hass, utcnow() + SCAN_INTERVAL) await hass.async_block_till_done() assert hass.states.get("switch.foo").state == STATE_UNKNOWN -async def test_update_timeout( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +@respx.mock +async def test_update_timeout(hass: HomeAssistant) -> None: """Test update when timeout occurs.""" - await _async_setup_test_switch(hass, aioclient_mock) + await _async_setup_test_switch(hass) - aioclient_mock.get(RESOURCE, exc=asyncio.TimeoutError()) + respx.get(RESOURCE).mock(side_effect=asyncio.TimeoutError()) async_fire_time_changed(hass, utcnow() + SCAN_INTERVAL) await hass.async_block_till_done() assert hass.states.get("switch.foo").state == STATE_UNKNOWN -async def test_entity_config( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +@respx.mock +async def test_entity_config(hass: HomeAssistant) -> None: """Test entity configuration.""" - aioclient_mock.get(RESOURCE, status=HTTPStatus.OK) + respx.get(RESOURCE) % HTTPStatus.OK config = { SWITCH_DOMAIN: { # REST configuration From b222c58642315cc840fcdd1101c101a64915f473 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 5 May 2023 14:43:56 +0200 Subject: [PATCH 210/822] Fix hassio get_os_info retry (#92569) --- homeassistant/components/hassio/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 78d974fe9cfe..42a51c218b12 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -590,7 +590,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: await async_setup_addon_panel(hass, hassio) # Setup hardware integration for the detected board type - async def _async_setup_hardware_integration(hass): + async def _async_setup_hardware_integration(_: datetime) -> None: """Set up hardaware integration for the detected board type.""" if (os_info := get_os_info(hass)) is None: # os info not yet fetched from supervisor, retry later @@ -610,7 +610,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: ) ) - await _async_setup_hardware_integration(hass) + await _async_setup_hardware_integration(datetime.now()) hass.async_create_task( hass.config_entries.flow.async_init(DOMAIN, context={"source": "system"}) From 8001ed865d5a1cb763bf4bf4157e9423a325d4df Mon Sep 17 00:00:00 2001 From: ollo69 <60491700+ollo69@users.noreply.github.com> Date: Fri, 5 May 2023 15:04:11 +0200 Subject: [PATCH 211/822] Fix switchbot binary sensor translation key (#92557) --- homeassistant/components/switchbot/binary_sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/switchbot/binary_sensor.py b/homeassistant/components/switchbot/binary_sensor.py index 00b22d9428cb..0a0cf40ca8b0 100644 --- a/homeassistant/components/switchbot/binary_sensor.py +++ b/homeassistant/components/switchbot/binary_sensor.py @@ -57,7 +57,7 @@ BINARY_SENSOR_TYPES: dict[str, BinarySensorEntityDescription] = { ), "unlocked_alarm": BinarySensorEntityDescription( key="unlocked_alarm", - translation_key="door_unclosed_alarm", + translation_key="door_unlocked_alarm", entity_category=EntityCategory.DIAGNOSTIC, device_class=BinarySensorDeviceClass.PROBLEM, ), From 70bfbde8aa81ba72b76394803cbef4edf56767bc Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 5 May 2023 16:05:03 +0200 Subject: [PATCH 212/822] Add ability to shutdown a coordinator on STOP (#92611) --- homeassistant/helpers/update_coordinator.py | 27 ++++++++++++++++- tests/helpers/test_update_coordinator.py | 32 +++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index c563ef09a52c..5706e34dc9ca 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -15,7 +15,8 @@ import aiohttp import requests from homeassistant import config_entries -from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import CALLBACK_TYPE, Event, HassJob, HomeAssistant, callback from homeassistant.exceptions import ( ConfigEntryAuthFailed, ConfigEntryError, @@ -97,6 +98,7 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_T]): job_name += f" {entry.title} {entry.domain} {entry.entry_id}" self._job = HassJob(self._handle_refresh_interval, job_name) self._unsub_refresh: CALLBACK_TYPE | None = None + self._unsub_shutdown: CALLBACK_TYPE | None = None self._request_refresh_task: asyncio.TimerHandle | None = None self.last_update_success = True self.last_exception: Exception | None = None @@ -117,6 +119,22 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_T]): if self.config_entry: self.config_entry.async_on_unload(self.async_shutdown) + async def async_register_shutdown(self) -> None: + """Register shutdown on HomeAssistant stop. + + Should only be used by coordinators that are not linked to a config entry. + """ + if self.config_entry: + raise RuntimeError("This should only be used outside of config entries.") + + async def _on_hass_stop(_: Event) -> None: + """Shutdown coordinator on HomeAssistant stop.""" + await self.async_shutdown() + + self._unsub_shutdown = self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, _on_hass_stop + ) + @callback def async_add_listener( self, update_callback: CALLBACK_TYPE, context: Any = None @@ -149,6 +167,7 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_T]): """Cancel any scheduled call, and ignore new runs.""" self._shutdown_requested = True self._async_unsub_refresh() + self._async_unsub_shutdown() await self._debounced_refresh.async_shutdown() @callback @@ -169,6 +188,12 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_T]): self._unsub_refresh() self._unsub_refresh = None + def _async_unsub_shutdown(self) -> None: + """Cancel any scheduled call.""" + if self._unsub_shutdown: + self._unsub_shutdown() + self._unsub_shutdown = None + @callback def _schedule_refresh(self) -> None: """Schedule a refresh.""" diff --git a/tests/helpers/test_update_coordinator.py b/tests/helpers/test_update_coordinator.py index 35ae78834c5c..91f761b5bb6a 100644 --- a/tests/helpers/test_update_coordinator.py +++ b/tests/helpers/test_update_coordinator.py @@ -186,6 +186,38 @@ async def test_shutdown_on_entry_unload( assert crd._unsub_refresh is None +async def test_shutdown_on_hass_stop( + hass: HomeAssistant, + crd: update_coordinator.DataUpdateCoordinator[int], +) -> None: + """Test shutdown can be shutdown on STOP event.""" + calls = 0 + + async def _refresh() -> int: + nonlocal calls + calls += 1 + return calls + + crd = update_coordinator.DataUpdateCoordinator[int]( + hass, + _LOGGER, + name="test", + update_method=_refresh, + update_interval=DEFAULT_UPDATE_INTERVAL, + ) + await crd.async_register_shutdown() + + crd.async_add_listener(lambda: None) + assert crd._unsub_refresh is not None + assert not crd._shutdown_requested + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + + assert crd._shutdown_requested + assert crd._unsub_refresh is None + + async def test_update_context( crd: update_coordinator.DataUpdateCoordinator[int], ) -> None: From 835be4758a9c498558f14ac14080d682758552e7 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 5 May 2023 16:22:07 +0200 Subject: [PATCH 213/822] Remove deprecated service for rainbird (#92601) --- homeassistant/components/rainbird/__init__.py | 64 +---------------- .../components/rainbird/strings.json | 11 --- tests/components/rainbird/test_init.py | 72 +------------------ 3 files changed, 5 insertions(+), 142 deletions(-) diff --git a/homeassistant/components/rainbird/__init__.py b/homeassistant/components/rainbird/__init__.py index 7b41a3f2f5ec..9c6605bcbba1 100644 --- a/homeassistant/components/rainbird/__init__.py +++ b/homeassistant/components/rainbird/__init__.py @@ -6,7 +6,7 @@ import logging from pyrainbird.async_client import AsyncRainbirdClient, AsyncRainbirdController import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry, ConfigEntryState +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_FRIENDLY_NAME, CONF_HOST, @@ -14,15 +14,13 @@ from homeassistant.const import ( CONF_TRIGGER_TIME, Platform, ) -from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er +from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType -from .const import ATTR_CONFIG_ENTRY_ID, ATTR_DURATION, CONF_SERIAL_NUMBER, CONF_ZONES +from .const import CONF_SERIAL_NUMBER, CONF_ZONES from .coordinator import RainbirdUpdateCoordinator PLATFORMS = [Platform.SWITCH, Platform.SENSOR, Platform.BINARY_SENSOR, Platform.NUMBER] @@ -54,16 +52,6 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) -SERVICE_SET_RAIN_DELAY = "set_rain_delay" -SERVICE_SCHEMA_RAIN_DELAY = vol.All( - vol.Schema( - { - vol.Required(ATTR_CONFIG_ENTRY_ID): cv.string, - vol.Required(ATTR_DURATION): cv.positive_float, - } - ), -) - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Rain Bird component.""" @@ -116,44 +104,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - async def set_rain_delay(call: ServiceCall) -> None: - """Service call to delay automatic irrigigation.""" - - entry_id = call.data[ATTR_CONFIG_ENTRY_ID] - duration = call.data[ATTR_DURATION] - if entry_id not in hass.data[DOMAIN]: - raise HomeAssistantError(f"Config entry id does not exist: {entry_id}") - coordinator = hass.data[DOMAIN][entry_id] - - entity_registry = er.async_get(hass) - entity_ids = ( - entry.entity_id - for entry in er.async_entries_for_config_entry(entity_registry, entry_id) - if entry.unique_id == f"{coordinator.serial_number}-rain-delay" - ) - async_create_issue( - hass, - DOMAIN, - "deprecated_raindelay", - breaks_in_ha_version="2023.4.0", - is_fixable=True, - is_persistent=True, - severity=IssueSeverity.WARNING, - translation_key="deprecated_raindelay", - translation_placeholders={ - "alternate_target": next(entity_ids, "unknown"), - }, - ) - - await coordinator.controller.set_rain_delay(duration) - - hass.services.async_register( - DOMAIN, - SERVICE_SET_RAIN_DELAY, - set_rain_delay, - schema=SERVICE_SCHEMA_RAIN_DELAY, - ) - return True @@ -163,12 +113,4 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): hass.data[DOMAIN].pop(entry.entry_id) - loaded_entries = [ - entry - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.state == ConfigEntryState.LOADED - ] - if len(loaded_entries) == 1: - hass.services.async_remove(DOMAIN, SERVICE_SET_RAIN_DELAY) - return unload_ok diff --git a/homeassistant/components/rainbird/strings.json b/homeassistant/components/rainbird/strings.json index f950146f1604..642612b11d25 100644 --- a/homeassistant/components/rainbird/strings.json +++ b/homeassistant/components/rainbird/strings.json @@ -32,17 +32,6 @@ "deprecated_yaml": { "title": "The Rain Bird YAML configuration is being removed", "description": "Configuring Rain Bird in configuration.yaml is being removed in Home Assistant 2023.4.\n\nYour configuration has been imported into the UI automatically, however default per-zone irrigation times are no longer supported. Remove the Rain Bird YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - }, - "deprecated_raindelay": { - "title": "The Rain Bird Rain Delay Service is being removed", - "fix_flow": { - "step": { - "confirm": { - "title": "The Rain Bird Rain Delay Service is being removed", - "description": "The Rain Bird service `rainbird.set_rain_delay` is being removed and replaced by a Number entity for managing the rain delay. Any existing automations or scripts will need to be updated to use `number.set_value` with a target of `{alternate_target}` instead." - } - } - } } } } diff --git a/tests/components/rainbird/test_init.py b/tests/components/rainbird/test_init.py index dedfbfe08642..8a7838c3caf0 100644 --- a/tests/components/rainbird/test_init.py +++ b/tests/components/rainbird/test_init.py @@ -5,25 +5,19 @@ from __future__ import annotations import pytest from homeassistant.components.rainbird import DOMAIN -from homeassistant.components.rainbird.const import ATTR_CONFIG_ENTRY_ID, ATTR_DURATION -from homeassistant.config_entries import ConfigEntry, ConfigEntryState -from homeassistant.const import Platform +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry as dr, issue_registry as ir from .conftest import ( - ACK_ECHO, CONFIG, CONFIG_ENTRY_DATA, - SERIAL_NUMBER, SERIAL_RESPONSE, UNAVAILABLE_RESPONSE, ComponentSetup, mock_response, ) -from tests.test_util.aiohttp import AiohttpClientMocker, AiohttpClientMockResponse +from tests.test_util.aiohttp import AiohttpClientMockResponse @pytest.mark.parametrize( @@ -100,65 +94,3 @@ async def test_communication_failure( assert [ entry.state for entry in hass.config_entries.async_entries(DOMAIN) ] == config_entry_states - - -@pytest.mark.parametrize("platforms", [[Platform.NUMBER, Platform.SENSOR]]) -async def test_rain_delay_service( - hass: HomeAssistant, - setup_integration: ComponentSetup, - aioclient_mock: AiohttpClientMocker, - responses: list[str], - config_entry: ConfigEntry, - issue_registry: ir.IssueRegistry, -) -> None: - """Test calling the rain delay service.""" - - assert await setup_integration() - - device_registry = dr.async_get(hass) - device = device_registry.async_get_device({(DOMAIN, SERIAL_NUMBER)}) - assert device - assert device.name == "Rain Bird Controller" - - aioclient_mock.mock_calls.clear() - responses.append(mock_response(ACK_ECHO)) - - await hass.services.async_call( - DOMAIN, - "set_rain_delay", - {ATTR_CONFIG_ENTRY_ID: config_entry.entry_id, ATTR_DURATION: 3}, - blocking=True, - ) - - assert len(aioclient_mock.mock_calls) == 1 - - issue = issue_registry.async_get_issue( - domain=DOMAIN, issue_id="deprecated_raindelay" - ) - assert issue - assert issue.translation_placeholders == { - "alternate_target": "number.rain_bird_controller_rain_delay" - } - - -async def test_rain_delay_invalid_config_entry( - hass: HomeAssistant, - setup_integration: ComponentSetup, - aioclient_mock: AiohttpClientMocker, - config_entry: ConfigEntry, -) -> None: - """Test calling the rain delay service.""" - - assert await setup_integration() - - aioclient_mock.mock_calls.clear() - - with pytest.raises(HomeAssistantError, match="Config entry id does not exist"): - await hass.services.async_call( - DOMAIN, - "set_rain_delay", - {ATTR_CONFIG_ENTRY_ID: "invalid", ATTR_DURATION: 3}, - blocking=True, - ) - - assert len(aioclient_mock.mock_calls) == 0 From e41a75f617a3495f6c9cf7c18155b89ad0078fce Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 5 May 2023 16:53:40 +0200 Subject: [PATCH 214/822] Remove yaml import rainbird (#92599) --- homeassistant/components/rainbird/__init__.py | 69 +------------------ .../components/rainbird/config_flow.py | 34 +-------- homeassistant/components/rainbird/const.py | 1 - .../components/rainbird/strings.json | 6 -- tests/components/rainbird/test_init.py | 40 +---------- tests/components/rainbird/test_switch.py | 29 +++----- 6 files changed, 18 insertions(+), 161 deletions(-) diff --git a/homeassistant/components/rainbird/__init__.py b/homeassistant/components/rainbird/__init__.py index 9c6605bcbba1..9cda4ec550f6 100644 --- a/homeassistant/components/rainbird/__init__.py +++ b/homeassistant/components/rainbird/__init__.py @@ -1,84 +1,21 @@ """Support for Rain Bird Irrigation system LNK WiFi Module.""" from __future__ import annotations -import logging - from pyrainbird.async_client import AsyncRainbirdClient, AsyncRainbirdController -import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import ( - CONF_FRIENDLY_NAME, - CONF_HOST, - CONF_PASSWORD, - CONF_TRIGGER_TIME, - Platform, -) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType -from .const import CONF_SERIAL_NUMBER, CONF_ZONES +from .const import CONF_SERIAL_NUMBER from .coordinator import RainbirdUpdateCoordinator PLATFORMS = [Platform.SWITCH, Platform.SENSOR, Platform.BINARY_SENSOR, Platform.NUMBER] -_LOGGER = logging.getLogger(__name__) DOMAIN = "rainbird" -TRIGGER_TIME_SCHEMA = vol.All( - cv.time_period, cv.positive_timedelta, lambda td: (td.total_seconds() // 60) -) - -ZONE_SCHEMA = vol.Schema( - { - vol.Optional(CONF_FRIENDLY_NAME): cv.string, - vol.Optional(CONF_TRIGGER_TIME): TRIGGER_TIME_SCHEMA, - } -) -CONTROLLER_SCHEMA = vol.Schema( - { - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_TRIGGER_TIME): TRIGGER_TIME_SCHEMA, - vol.Optional(CONF_ZONES): vol.Schema({cv.positive_int: ZONE_SCHEMA}), - } -) -CONFIG_SCHEMA = vol.Schema( - {DOMAIN: vol.Schema(vol.All(cv.ensure_list, [CONTROLLER_SCHEMA]))}, - extra=vol.ALLOW_EXTRA, -) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Rain Bird component.""" - if DOMAIN not in config: - return True - - for controller_config in config[DOMAIN]: - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=controller_config, - ) - ) - - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml", - breaks_in_ha_version="2023.4.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - ) - - return True - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the config entry for Rain Bird.""" diff --git a/homeassistant/components/rainbird/config_flow.py b/homeassistant/components/rainbird/config_flow.py index 3ade67c79f72..0409d0ff564c 100644 --- a/homeassistant/components/rainbird/config_flow.py +++ b/homeassistant/components/rainbird/config_flow.py @@ -16,7 +16,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_FRIENDLY_NAME, CONF_HOST, CONF_PASSWORD +from homeassistant.const import CONF_HOST, CONF_PASSWORD from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_validation as cv, selector @@ -24,9 +24,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import ( ATTR_DURATION, - CONF_IMPORTED_NAMES, CONF_SERIAL_NUMBER, - CONF_ZONES, DEFAULT_TRIGGER_TIME_MINUTES, DOMAIN, TIMEOUT_SECONDS, @@ -121,36 +119,6 @@ class RainbirdConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): "cannot_connect", ) from err - async def async_step_import(self, config: dict[str, Any]) -> FlowResult: - """Import a config entry from configuration.yaml.""" - self._async_abort_entries_match({CONF_HOST: config[CONF_HOST]}) - try: - serial_number = await self._test_connection( - config[CONF_HOST], config[CONF_PASSWORD] - ) - except ConfigFlowError as err: - _LOGGER.error("Error during config import: %s", err) - return self.async_abort(reason=err.error_code) - - data = { - CONF_HOST: config[CONF_HOST], - CONF_PASSWORD: config[CONF_PASSWORD], - CONF_SERIAL_NUMBER: serial_number, - } - names: dict[str, str] = {} - for zone, zone_config in config.get(CONF_ZONES, {}).items(): - if name := zone_config.get(CONF_FRIENDLY_NAME): - names[str(zone)] = name - if names: - data[CONF_IMPORTED_NAMES] = names - return await self.async_finish( - serial_number, - data=data, - options={ - ATTR_DURATION: config.get(ATTR_DURATION, DEFAULT_TRIGGER_TIME_MINUTES), - }, - ) - async def async_finish( self, serial_number: str, diff --git a/homeassistant/components/rainbird/const.py b/homeassistant/components/rainbird/const.py index 162e3a16b6c6..8055074f395c 100644 --- a/homeassistant/components/rainbird/const.py +++ b/homeassistant/components/rainbird/const.py @@ -4,7 +4,6 @@ DOMAIN = "rainbird" MANUFACTURER = "Rain Bird" DEFAULT_TRIGGER_TIME_MINUTES = 6 -CONF_ZONES = "zones" CONF_SERIAL_NUMBER = "serial_number" CONF_IMPORTED_NAMES = "imported_names" diff --git a/homeassistant/components/rainbird/strings.json b/homeassistant/components/rainbird/strings.json index 642612b11d25..3b5ae332dbd4 100644 --- a/homeassistant/components/rainbird/strings.json +++ b/homeassistant/components/rainbird/strings.json @@ -27,11 +27,5 @@ } } } - }, - "issues": { - "deprecated_yaml": { - "title": "The Rain Bird YAML configuration is being removed", - "description": "Configuring Rain Bird in configuration.yaml is being removed in Home Assistant 2023.4.\n\nYour configuration has been imported into the UI automatically, however default per-zone irrigation times are no longer supported. Remove the Rain Bird YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } } } diff --git a/tests/components/rainbird/test_init.py b/tests/components/rainbird/test_init.py index 8a7838c3caf0..1330f1cb4b2a 100644 --- a/tests/components/rainbird/test_init.py +++ b/tests/components/rainbird/test_init.py @@ -8,14 +8,7 @@ from homeassistant.components.rainbird import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from .conftest import ( - CONFIG, - CONFIG_ENTRY_DATA, - SERIAL_RESPONSE, - UNAVAILABLE_RESPONSE, - ComponentSetup, - mock_response, -) +from .conftest import CONFIG_ENTRY_DATA, UNAVAILABLE_RESPONSE, ComponentSetup from tests.test_util.aiohttp import AiohttpClientMockResponse @@ -24,18 +17,8 @@ from tests.test_util.aiohttp import AiohttpClientMockResponse ("yaml_config", "config_entry_data", "initial_response"), [ ({}, CONFIG_ENTRY_DATA, None), - ( - CONFIG, - None, - mock_response(SERIAL_RESPONSE), # Extra import request - ), - ( - CONFIG, - CONFIG_ENTRY_DATA, - None, - ), ], - ids=["config_entry", "yaml", "already_exists"], + ids=["config_entry"], ) async def test_init_success( hass: HomeAssistant, @@ -62,25 +45,8 @@ async def test_init_success( ("yaml_config", "config_entry_data", "responses", "config_entry_states"), [ ({}, CONFIG_ENTRY_DATA, [UNAVAILABLE_RESPONSE], [ConfigEntryState.SETUP_RETRY]), - ( - CONFIG, - None, - [ - UNAVAILABLE_RESPONSE, # Failure when importing yaml - ], - [], - ), - ( - CONFIG, - None, - [ - mock_response(SERIAL_RESPONSE), # Import succeeds - UNAVAILABLE_RESPONSE, # Failure on integration setup - ], - [ConfigEntryState.SETUP_RETRY], - ), ], - ids=["config_entry_failure", "yaml_import_failure", "yaml_init_failure"], + ids=["config_entry_failure"], ) async def test_communication_failure( hass: HomeAssistant, diff --git a/tests/components/rainbird/test_switch.py b/tests/components/rainbird/test_switch.py index 8eeb59dd2da2..684287a5d1a4 100644 --- a/tests/components/rainbird/test_switch.py +++ b/tests/components/rainbird/test_switch.py @@ -14,7 +14,6 @@ from .conftest import ( PASSWORD, RAIN_DELAY_OFF, RAIN_SENSOR_OFF, - SERIAL_RESPONSE, ZONE_3_ON_RESPONSE, ZONE_5_ON_RESPONSE, ZONE_OFF_RESPONSE, @@ -214,32 +213,26 @@ async def test_irrigation_service( ("yaml_config", "config_entry_data"), [ ( + {}, { - DOMAIN: { - "host": HOST, - "password": PASSWORD, - "trigger_time": 360, - "zones": { - 1: { - "friendly_name": "Garden Sprinkler", - }, - 2: { - "friendly_name": "Back Yard", - }, - }, - } + "host": HOST, + "password": PASSWORD, + "trigger_time": 360, + "serial_number": "0x1263613994342", + "imported_names": { + "1": "Garden Sprinkler", + "2": "Back Yard", + }, }, - None, ) ], ) -async def test_yaml_config( +async def test_yaml_imported_config( hass: HomeAssistant, setup_integration: ComponentSetup, responses: list[AiohttpClientMockResponse], ) -> None: - """Test switch platform with fake data that creates 7 zones with one enabled.""" - responses.insert(0, mock_response(SERIAL_RESPONSE)) # Extra import request + """Test a config entry that was previously imported from yaml.""" assert await setup_integration() assert hass.states.get("switch.garden_sprinkler") From 53e533af6b928509071c688a5090874775849ccb Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 5 May 2023 19:21:57 +0200 Subject: [PATCH 215/822] Improve and enforce type hints for imap (#92325) --- .strict-typing | 1 + homeassistant/components/imap/coordinator.py | 12 ++++++------ mypy.ini | 10 ++++++++++ 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/.strict-typing b/.strict-typing index a5f084116a2a..cfc9d741ca7c 100644 --- a/.strict-typing +++ b/.strict-typing @@ -170,6 +170,7 @@ homeassistant.components.hyperion.* homeassistant.components.ibeacon.* homeassistant.components.image_processing.* homeassistant.components.image_upload.* +homeassistant.components.imap.* homeassistant.components.input_button.* homeassistant.components.input_select.* homeassistant.components.integration.* diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index 666a82c73d45..bdf1390ae208 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -108,9 +108,9 @@ class ImapMessage: Will look for text/plain or use text/html if not found. """ - message_text = None - message_html = None - message_untyped_text = None + message_text: str | None = None + message_html: str | None = None + message_untyped_text: str | None = None for part in self.email_message.walk(): if part.get_content_type() == CONTENT_TYPE_TEXT_PLAIN: @@ -134,7 +134,7 @@ class ImapMessage: if message_untyped_text is not None: return message_untyped_text - return self.email_message.get_payload() + return str(self.email_message.get_payload()) class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): @@ -231,7 +231,7 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): _LOGGER.debug("Error while cleaning up imap connection") self.imap_client = None - async def shutdown(self, *_) -> None: + async def shutdown(self, *_: Any) -> None: """Close resources.""" await self._cleanup(log_error=True) @@ -336,7 +336,7 @@ class ImapPushDataUpdateCoordinator(ImapDataUpdateCoordinator): await self._cleanup() await asyncio.sleep(BACKOFF_TIME) - async def shutdown(self, *_) -> None: + async def shutdown(self, *_: Any) -> None: """Close resources.""" if self._push_wait_task: self._push_wait_task.cancel() diff --git a/mypy.ini b/mypy.ini index 2fe9310907f3..6f1082aad719 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1462,6 +1462,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.imap.*] +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.input_button.*] check_untyped_defs = true disallow_incomplete_defs = true From 9a589a3a544dc8bc3c3271475f683d234c120c1c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 May 2023 13:26:58 -0500 Subject: [PATCH 216/822] Improve reliability of ONVIF subscription renewals (#92551) * Improve reliablity of onvif subscription renewals upstream changelog: https://github.com/hunterjm/python-onvif-zeep-async/compare/v2.0.0...v2.1.0 * ``` Traceback (most recent call last): File "/Users/bdraco/home-assistant/venv/lib/python3.10/site-packages/onvif/client.py", line 75, in _async_wrap_connection_error_retry return await func(*args, **kwargs) File "/Users/bdraco/home-assistant/homeassistant/components/onvif/event.py", line 441, in _async_call_pullpoint_subscription_renew await self._pullpoint_subscription.Renew(SUBSCRIPTION_RELATIVE_TIME) File "/Users/bdraco/home-assistant/venv/lib/python3.10/site-packages/zeep/proxy.py", line 64, in __call__ return await self._proxy._binding.send_async( File "/Users/bdraco/home-assistant/venv/lib/python3.10/site-packages/zeep/wsdl/bindings/soap.py", line 156, in send_async response = await client.transport.post_xml( File "/Users/bdraco/home-assistant/venv/lib/python3.10/site-packages/zeep/transports.py", line 235, in post_xml response = await self.post(address, message, headers) File "/Users/bdraco/home-assistant/venv/lib/python3.10/site-packages/zeep/transports.py", line 220, in post response = await self.client.post( File "/Users/bdraco/home-assistant/venv/lib/python3.10/site-packages/httpx/_client.py", line 1845, in post return await self.request( File "/Users/bdraco/home-assistant/venv/lib/python3.10/site-packages/httpx/_client.py", line 1530, in request return await self.send(request, auth=auth, follow_redirects=follow_redirects) File "/Users/bdraco/home-assistant/venv/lib/python3.10/site-packages/httpx/_client.py", line 1617, in send response = await self._send_handling_auth( File "/Users/bdraco/home-assistant/venv/lib/python3.10/site-packages/httpx/_client.py", line 1645, in _send_handling_auth response = await self._send_handling_redirects( File "/Users/bdraco/home-assistant/venv/lib/python3.10/site-packages/httpx/_client.py", line 1682, in _send_handling_redirects response = await self._send_single_request(request) File "/Users/bdraco/home-assistant/venv/lib/python3.10/site-packages/httpx/_client.py", line 1719, in _send_single_request response = await transport.handle_async_request(request) File "/Users/bdraco/home-assistant/venv/lib/python3.10/site-packages/httpx/_transports/default.py", line 352, in handle_async_request with map_httpcore_exceptions(): File "/opt/homebrew/Cellar/python@3.10/3.10.10_1/Frameworks/Python.framework/Versions/3.10/lib/python3.10/contextlib.py", line 153, in __exit__ self.gen.throw(typ, value, traceback) File "/Users/bdraco/home-assistant/venv/lib/python3.10/site-packages/httpx/_transports/default.py", line 77, in map_httpcore_exceptions raise mapped_exc(message) from exc httpx.ReadTimeout ``` * adjust timeouts for slower tplink cameras * tweak * more debug * tweak * adjust message * tweak * Revert "tweak" This reverts commit 10ee2a8de70e93dc5be85b1992ec4d30c2188344. * give time in seconds * revert * revert * Update homeassistant/components/onvif/event.py * Update homeassistant/components/onvif/event.py --- homeassistant/components/onvif/event.py | 134 ++++++++----------- homeassistant/components/onvif/manifest.json | 2 +- homeassistant/components/onvif/util.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 62 insertions(+), 80 deletions(-) diff --git a/homeassistant/components/onvif/event.py b/homeassistant/components/onvif/event.py index 92f76b6a9508..35df9221934f 100644 --- a/homeassistant/components/onvif/event.py +++ b/homeassistant/components/onvif/event.py @@ -9,7 +9,7 @@ import datetime as dt from aiohttp.web import Request from httpx import RemoteProtocolError, RequestError, TransportError from onvif import ONVIFCamera, ONVIFService -from onvif.client import NotificationManager +from onvif.client import NotificationManager, retry_connection_error from onvif.exceptions import ONVIFError from zeep.exceptions import Fault, ValidationError, XMLParseError @@ -40,8 +40,8 @@ SET_SYNCHRONIZATION_POINT_ERRORS = (*SUBSCRIPTION_ERRORS, TypeError) UNSUBSCRIBE_ERRORS = (XMLParseError, *SUBSCRIPTION_ERRORS) RENEW_ERRORS = (ONVIFError, RequestError, XMLParseError, *SUBSCRIPTION_ERRORS) # -# We only keep the subscription alive for 3 minutes, and will keep -# renewing it every 1.5 minutes. This is to avoid the camera +# We only keep the subscription alive for 10 minutes, and will keep +# renewing it every 8 minutes. This is to avoid the camera # accumulating subscriptions which will be impossible to clean up # since ONVIF does not provide a way to list existing subscriptions. # @@ -49,12 +49,25 @@ RENEW_ERRORS = (ONVIFError, RequestError, XMLParseError, *SUBSCRIPTION_ERRORS) # sending events to us, and we will not be able to recover until # the subscriptions expire or the camera is rebooted. # -SUBSCRIPTION_TIME = dt.timedelta(minutes=3) -SUBSCRIPTION_RELATIVE_TIME = ( - "PT3M" # use relative time since the time on the camera is not reliable -) -SUBSCRIPTION_RENEW_INTERVAL = SUBSCRIPTION_TIME.total_seconds() / 2 -SUBSCRIPTION_RENEW_INTERVAL_ON_ERROR = 60.0 +SUBSCRIPTION_TIME = dt.timedelta(minutes=10) + +# SUBSCRIPTION_RELATIVE_TIME uses a relative time since the time on the camera +# is not reliable. We use 600 seconds (10 minutes) since some cameras cannot +# parse time in the format "PT10M" (10 minutes). +SUBSCRIPTION_RELATIVE_TIME = "PT600S" + +# SUBSCRIPTION_RENEW_INTERVAL Must be less than the +# overall timeout of 90 * (SUBSCRIPTION_ATTEMPTS) 2 = 180 seconds +# +# We use 8 minutes between renewals to make sure we never hit the +# 10 minute limit even if the first renewal attempt fails +SUBSCRIPTION_RENEW_INTERVAL = 8 * 60 + +# The number of attempts to make when creating or renewing a subscription +SUBSCRIPTION_ATTEMPTS = 2 + +# The time to wait before trying to restart the subscription if it fails +SUBSCRIPTION_RESTART_INTERVAL_ON_ERROR = 60 PULLPOINT_POLL_TIME = dt.timedelta(seconds=60) PULLPOINT_MESSAGE_LIMIT = 100 @@ -327,20 +340,7 @@ class PullPointManager: async def _async_start_pullpoint(self) -> bool: """Start pullpoint subscription.""" try: - try: - started = await self._async_create_pullpoint_subscription() - except RequestError: - # - # We should only need to retry on RemoteProtocolError but some cameras - # are flaky and sometimes do not respond to the Renew request so we - # retry on RequestError as well. - # - # For RemoteProtocolError: - # http://datatracker.ietf.org/doc/html/rfc2616#section-8.1.4 allows the server - # to close the connection at any time, we treat this as a normal and try again - # once since we do not want to declare the camera as not supporting PullPoint - # if it just happened to close the connection at the wrong time. - started = await self._async_create_pullpoint_subscription() + started = await self._async_create_pullpoint_subscription() except CREATE_ERRORS as err: LOGGER.debug( "%s: Device does not support PullPoint service or has too many subscriptions: %s", @@ -372,16 +372,16 @@ class PullPointManager: # scheduled when the current one is done if needed. return async with self._renew_lock: - next_attempt = SUBSCRIPTION_RENEW_INTERVAL_ON_ERROR + next_attempt = SUBSCRIPTION_RESTART_INTERVAL_ON_ERROR try: - if ( - await self._async_renew_pullpoint() - or await self._async_restart_pullpoint() - ): + if await self._async_renew_pullpoint(): next_attempt = SUBSCRIPTION_RENEW_INTERVAL + else: + await self._async_restart_pullpoint() finally: self.async_schedule_pullpoint_renew(next_attempt) + @retry_connection_error(SUBSCRIPTION_ATTEMPTS) async def _async_create_pullpoint_subscription(self) -> bool: """Create pullpoint subscription.""" @@ -447,6 +447,11 @@ class PullPointManager: ) self._pullpoint_subscription = None + @retry_connection_error(SUBSCRIPTION_ATTEMPTS) + async def _async_call_pullpoint_subscription_renew(self) -> None: + """Call PullPoint subscription Renew.""" + await self._pullpoint_subscription.Renew(SUBSCRIPTION_RELATIVE_TIME) + async def _async_renew_pullpoint(self) -> bool: """Renew the PullPoint subscription.""" if ( @@ -458,20 +463,7 @@ class PullPointManager: # The first time we renew, we may get a Fault error so we # suppress it. The subscription will be restarted in # async_restart later. - try: - await self._pullpoint_subscription.Renew(SUBSCRIPTION_RELATIVE_TIME) - except RequestError: - # - # We should only need to retry on RemoteProtocolError but some cameras - # are flaky and sometimes do not respond to the Renew request so we - # retry on RequestError as well. - # - # For RemoteProtocolError: - # http://datatracker.ietf.org/doc/html/rfc2616#section-8.1.4 allows the server - # to close the connection at any time, we treat this as a normal and try again - # once since we do not want to mark events as stale - # if it just happened to close the connection at the wrong time. - await self._pullpoint_subscription.Renew(SUBSCRIPTION_RELATIVE_TIME) + await self._async_call_pullpoint_subscription_renew() LOGGER.debug("%s: Renewed PullPoint subscription", self._name) return True except RENEW_ERRORS as err: @@ -521,7 +513,7 @@ class PullPointManager: stringify_onvif_error(err), ) return True - except (XMLParseError, *SUBSCRIPTION_ERRORS) as err: + except Fault as err: # Device may not support subscriptions so log at debug level # when we get an XMLParseError LOGGER.debug( @@ -532,6 +524,16 @@ class PullPointManager: # Treat errors as if the camera restarted. Assume that the pullpoint # subscription is no longer valid. return False + except (XMLParseError, RequestError, TimeoutError, TransportError) as err: + LOGGER.debug( + "%s: PullPoint subscription encountered an unexpected error and will be retried " + "(this is normal for some cameras): %s", + self._name, + stringify_onvif_error(err), + ) + # Avoid renewing the subscription too often since it causes problems + # for some cameras, mainly the Tapo ones. + return True if self.state != PullPointManagerState.STARTED: # If the webhook became started working during the long poll, @@ -655,6 +657,7 @@ class WebHookManager: self._renew_or_restart_job, ) + @retry_connection_error(SUBSCRIPTION_ATTEMPTS) async def _async_create_webhook_subscription(self) -> None: """Create webhook subscription.""" LOGGER.debug( @@ -689,20 +692,7 @@ class WebHookManager: async def _async_start_webhook(self) -> bool: """Start webhook.""" try: - try: - await self._async_create_webhook_subscription() - except RequestError: - # - # We should only need to retry on RemoteProtocolError but some cameras - # are flaky and sometimes do not respond to the Renew request so we - # retry on RequestError as well. - # - # For RemoteProtocolError: - # http://datatracker.ietf.org/doc/html/rfc2616#section-8.1.4 allows the server - # to close the connection at any time, we treat this as a normal and try again - # once since we do not want to declare the camera as not supporting webhooks - # if it just happened to close the connection at the wrong time. - await self._async_create_webhook_subscription() + await self._async_create_webhook_subscription() except CREATE_ERRORS as err: self._event_manager.async_webhook_failed() LOGGER.debug( @@ -720,6 +710,12 @@ class WebHookManager: await self._async_unsubscribe_webhook() return await self._async_start_webhook() + @retry_connection_error(SUBSCRIPTION_ATTEMPTS) + async def _async_call_webhook_subscription_renew(self) -> None: + """Call PullPoint subscription Renew.""" + assert self._webhook_subscription is not None + await self._webhook_subscription.Renew(SUBSCRIPTION_RELATIVE_TIME) + async def _async_renew_webhook(self) -> bool: """Renew webhook subscription.""" if ( @@ -728,20 +724,7 @@ class WebHookManager: ): return False try: - try: - await self._webhook_subscription.Renew(SUBSCRIPTION_RELATIVE_TIME) - except RequestError: - # - # We should only need to retry on RemoteProtocolError but some cameras - # are flaky and sometimes do not respond to the Renew request so we - # retry on RequestError as well. - # - # For RemoteProtocolError: - # http://datatracker.ietf.org/doc/html/rfc2616#section-8.1.4 allows the server - # to close the connection at any time, we treat this as a normal and try again - # once since we do not want to mark events as stale - # if it just happened to close the connection at the wrong time. - await self._webhook_subscription.Renew(SUBSCRIPTION_RELATIVE_TIME) + await self._async_call_webhook_subscription_renew() LOGGER.debug("%s: Renewed Webhook subscription", self._name) return True except RENEW_ERRORS as err: @@ -765,13 +748,12 @@ class WebHookManager: # scheduled when the current one is done if needed. return async with self._renew_lock: - next_attempt = SUBSCRIPTION_RENEW_INTERVAL_ON_ERROR + next_attempt = SUBSCRIPTION_RESTART_INTERVAL_ON_ERROR try: - if ( - await self._async_renew_webhook() - or await self._async_restart_webhook() - ): + if await self._async_renew_webhook(): next_attempt = SUBSCRIPTION_RENEW_INTERVAL + else: + await self._async_restart_webhook() finally: self._async_schedule_webhook_renew(next_attempt) diff --git a/homeassistant/components/onvif/manifest.json b/homeassistant/components/onvif/manifest.json index 9fc0d4178380..f29fd5621049 100644 --- a/homeassistant/components/onvif/manifest.json +++ b/homeassistant/components/onvif/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/onvif", "iot_class": "local_push", "loggers": ["onvif", "wsdiscovery", "zeep"], - "requirements": ["onvif-zeep-async==2.0.0", "WSDiscovery==2.0.0"] + "requirements": ["onvif-zeep-async==2.1.1", "WSDiscovery==2.0.0"] } diff --git a/homeassistant/components/onvif/util.py b/homeassistant/components/onvif/util.py index 978473caa249..a88a37f5d205 100644 --- a/homeassistant/components/onvif/util.py +++ b/homeassistant/components/onvif/util.py @@ -34,7 +34,7 @@ def stringify_onvif_error(error: Exception) -> str: message += f" (actor:{error.actor})" else: message = str(error) - return message or "Device sent empty error" + return message or f"Device sent empty error with type {type(error)}" def is_auth_error(error: Exception) -> bool: diff --git a/requirements_all.txt b/requirements_all.txt index e7f62aa185da..d863c93fc3cc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1258,7 +1258,7 @@ ondilo==0.2.0 onkyo-eiscp==1.2.7 # homeassistant.components.onvif -onvif-zeep-async==2.0.0 +onvif-zeep-async==2.1.1 # homeassistant.components.opengarage open-garage==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 10e0e53bd762..21f880490c75 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -945,7 +945,7 @@ omnilogic==0.4.5 ondilo==0.2.0 # homeassistant.components.onvif -onvif-zeep-async==2.0.0 +onvif-zeep-async==2.1.1 # homeassistant.components.opengarage open-garage==0.2.0 From 6d1e607756c5678a6cdf0f1823918f18490b8c2f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 5 May 2023 20:27:28 +0200 Subject: [PATCH 217/822] Lower scan interval for OpenSky (#92593) * Lower scan interval for opensky to avoid hitting rate limit * Lower scan interval for opensky to avoid hitting rate limit * Update homeassistant/components/opensky/sensor.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/opensky/sensor.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/opensky/sensor.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/opensky/sensor.py b/homeassistant/components/opensky/sensor.py index 4c96f2575f05..03e242f40b29 100644 --- a/homeassistant/components/opensky/sensor.py +++ b/homeassistant/components/opensky/sensor.py @@ -38,7 +38,8 @@ DEFAULT_ALTITUDE = 0 EVENT_OPENSKY_ENTRY = f"{DOMAIN}_entry" EVENT_OPENSKY_EXIT = f"{DOMAIN}_exit" -SCAN_INTERVAL = timedelta(seconds=12) # opensky public limit is 10 seconds +# OpenSky free user has 400 credits, with 4 credits per API call. 100/24 = ~4 requests per hour +SCAN_INTERVAL = timedelta(minutes=15) OPENSKY_API_URL = "https://opensky-network.org/api/states/all" OPENSKY_API_FIELDS = [ From cd02a67847c7c253992c274071ee5e3b9c2bac68 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 5 May 2023 20:27:48 +0200 Subject: [PATCH 218/822] Update frontend to 20230503.3 (#92617) --- 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 41b363b63889..4e1e0a74fe9b 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==20230503.2"] + "requirements": ["home-assistant-frontend==20230503.3"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index a3dc5ad71232..7d4b54f98a2b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -25,7 +25,7 @@ ha-av==10.0.0 hass-nabucasa==0.66.2 hassil==1.0.6 home-assistant-bluetooth==1.10.0 -home-assistant-frontend==20230503.2 +home-assistant-frontend==20230503.3 home-assistant-intents==2023.4.26 httpx==0.24.0 ifaddr==0.1.7 diff --git a/requirements_all.txt b/requirements_all.txt index d863c93fc3cc..e41fe759434b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -911,7 +911,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230503.2 +home-assistant-frontend==20230503.3 # homeassistant.components.conversation home-assistant-intents==2023.4.26 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 21f880490c75..ec099241382c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -700,7 +700,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230503.2 +home-assistant-frontend==20230503.3 # homeassistant.components.conversation home-assistant-intents==2023.4.26 From c8c1466c06d0dab90e197272e4843b18a7cfc2d5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 May 2023 13:32:55 -0500 Subject: [PATCH 219/822] Fix duplicate ONVIF sensors (#92629) Some cameras do not configure the video source correctly when using webhooks but work fine with PullPoint which results in duplicate sensors --- homeassistant/components/onvif/parsers.py | 33 ++++++++++++++++------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/onvif/parsers.py b/homeassistant/components/onvif/parsers.py index abb1f114ce52..8e6e3e25861c 100644 --- a/homeassistant/components/onvif/parsers.py +++ b/homeassistant/components/onvif/parsers.py @@ -15,6 +15,19 @@ PARSERS: Registry[ str, Callable[[str, Any], Coroutine[Any, Any, Event | None]] ] = Registry() +VIDEO_SOURCE_MAPPING = { + "vsconf": "VideoSourceToken", +} + + +def _normalize_video_source(source: str) -> str: + """Normalize video source. + + Some cameras do not set the VideoSourceToken correctly so we get duplicate + sensors, so we need to normalize it to the correct value. + """ + return VIDEO_SOURCE_MAPPING.get(source, source) + def local_datetime_or_none(value: str) -> datetime.datetime | None: """Convert strings to datetimes, if invalid, return None.""" @@ -188,7 +201,7 @@ async def async_parse_field_detector(uid: str, msg) -> Event | None: rule = "" for source in msg.Message._value_1.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": - video_source = source.Value + video_source = _normalize_video_source(source.Value) if source.Name == "VideoAnalyticsConfigurationToken": video_analytics = source.Value if source.Name == "Rule": @@ -220,7 +233,7 @@ async def async_parse_cell_motion_detector(uid: str, msg) -> Event | None: rule = "" for source in msg.Message._value_1.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": - video_source = source.Value + video_source = _normalize_video_source(source.Value) if source.Name == "VideoAnalyticsConfigurationToken": video_analytics = source.Value if source.Name == "Rule": @@ -251,7 +264,7 @@ async def async_parse_motion_region_detector(uid: str, msg) -> Event | None: rule = "" for source in msg.Message._value_1.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": - video_source = source.Value + video_source = _normalize_video_source(source.Value) if source.Name == "VideoAnalyticsConfigurationToken": video_analytics = source.Value if source.Name == "Rule": @@ -282,7 +295,7 @@ async def async_parse_tamper_detector(uid: str, msg) -> Event | None: rule = "" for source in msg.Message._value_1.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": - video_source = source.Value + video_source = _normalize_video_source(source.Value) if source.Name == "VideoAnalyticsConfigurationToken": video_analytics = source.Value if source.Name == "Rule": @@ -312,7 +325,7 @@ async def async_parse_dog_cat_detector(uid: str, msg) -> Event | None: video_source = "" for source in msg.Message._value_1.Source.SimpleItem: if source.Name == "Source": - video_source = source.Value + video_source = _normalize_video_source(source.Value) return Event( f"{uid}_{msg.Topic._value_1}_{video_source}", @@ -337,7 +350,7 @@ async def async_parse_vehicle_detector(uid: str, msg) -> Event | None: video_source = "" for source in msg.Message._value_1.Source.SimpleItem: if source.Name == "Source": - video_source = source.Value + video_source = _normalize_video_source(source.Value) return Event( f"{uid}_{msg.Topic._value_1}_{video_source}", @@ -362,7 +375,7 @@ async def async_parse_person_detector(uid: str, msg) -> Event | None: video_source = "" for source in msg.Message._value_1.Source.SimpleItem: if source.Name == "Source": - video_source = source.Value + video_source = _normalize_video_source(source.Value) return Event( f"{uid}_{msg.Topic._value_1}_{video_source}", @@ -387,7 +400,7 @@ async def async_parse_face_detector(uid: str, msg) -> Event | None: video_source = "" for source in msg.Message._value_1.Source.SimpleItem: if source.Name == "Source": - video_source = source.Value + video_source = _normalize_video_source(source.Value) return Event( f"{uid}_{msg.Topic._value_1}_{video_source}", @@ -412,7 +425,7 @@ async def async_parse_visitor_detector(uid: str, msg) -> Event | None: video_source = "" for source in msg.Message._value_1.Source.SimpleItem: if source.Name == "Source": - video_source = source.Value + video_source = _normalize_video_source(source.Value) return Event( f"{uid}_{msg.Topic._value_1}_{video_source}", @@ -683,7 +696,7 @@ async def async_parse_count_aggregation_counter(uid: str, msg) -> Event | None: rule = "" for source in msg.Message._value_1.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": - video_source = source.Value + video_source = _normalize_video_source(source.Value) if source.Name == "VideoAnalyticsConfigurationToken": video_analytics = source.Value if source.Name == "Rule": From bcbc8539a6c22329b1a54c67cf02f83561f46ac7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 May 2023 13:38:36 -0500 Subject: [PATCH 220/822] Fix missing ONVIF events when switching from PullPoint to webhooks (#92627) We now let the PullPoint subscription expire instead of explicitly unsubscribing when pausing the subscription. We will still unsubscribe it if Home Assistant is shutdown or the integration is reloaded Some cameras will cancel ALL subscriptions when we do an unsubscribe so we want to let the PullPoint subscription expire instead of explicitly cancelling it. --- homeassistant/components/onvif/event.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/onvif/event.py b/homeassistant/components/onvif/event.py index 35df9221934f..507eda600971 100644 --- a/homeassistant/components/onvif/event.py +++ b/homeassistant/components/onvif/event.py @@ -289,7 +289,13 @@ class PullPointManager: """Pause pullpoint subscription.""" LOGGER.debug("%s: Pausing PullPoint manager", self._name) self.state = PullPointManagerState.PAUSED - self._hass.async_create_task(self._async_cancel_and_unsubscribe()) + # Cancel the renew job so we don't renew the subscription + # and stop pulling messages. + self._async_cancel_pullpoint_renew() + self.async_cancel_pull_messages() + # We do not unsubscribe from the pullpoint subscription and instead + # let the subscription expire since some cameras will terminate all + # subscriptions if we unsubscribe which will break the webhook. @callback def async_resume(self) -> None: From 6bbcf2f689cb1da55d2f5159869049188e2971d9 Mon Sep 17 00:00:00 2001 From: Steve Easley Date: Fri, 5 May 2023 14:44:53 -0400 Subject: [PATCH 221/822] Add JVC Projector integration (#84748) * Initial commit of jvcprojector * Renamed domain * Initial commit * Support for v1.0.6 device api * Fixed failing test * Removed TYPE_CHECKING constant * Removed jvc brand * Removed constant rename * Renaming more constants * Renaming yet more constants * Improved config_flow tests * More changes based on feedback * Moved config_flow dependency * Removed default translation title * Removed translation file * Order manifest properly --- .strict-typing | 1 + CODEOWNERS | 2 + .../components/jvc_projector/__init__.py | 65 ++++ .../components/jvc_projector/config_flow.py | 129 ++++++++ .../components/jvc_projector/const.py | 5 + .../components/jvc_projector/coordinator.py | 62 ++++ .../components/jvc_projector/entity.py | 38 +++ .../components/jvc_projector/manifest.json | 11 + .../components/jvc_projector/remote.py | 76 +++++ .../components/jvc_projector/strings.json | 35 +++ 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/jvc_projector/__init__.py | 7 + tests/components/jvc_projector/conftest.py | 58 ++++ .../jvc_projector/test_config_flow.py | 297 ++++++++++++++++++ .../jvc_projector/test_coordinator.py | 73 +++++ tests/components/jvc_projector/test_init.py | 71 +++++ tests/components/jvc_projector/test_remote.py | 77 +++++ 21 files changed, 1030 insertions(+) create mode 100644 homeassistant/components/jvc_projector/__init__.py create mode 100644 homeassistant/components/jvc_projector/config_flow.py create mode 100644 homeassistant/components/jvc_projector/const.py create mode 100644 homeassistant/components/jvc_projector/coordinator.py create mode 100644 homeassistant/components/jvc_projector/entity.py create mode 100644 homeassistant/components/jvc_projector/manifest.json create mode 100644 homeassistant/components/jvc_projector/remote.py create mode 100644 homeassistant/components/jvc_projector/strings.json create mode 100644 tests/components/jvc_projector/__init__.py create mode 100644 tests/components/jvc_projector/conftest.py create mode 100644 tests/components/jvc_projector/test_config_flow.py create mode 100644 tests/components/jvc_projector/test_coordinator.py create mode 100644 tests/components/jvc_projector/test_init.py create mode 100644 tests/components/jvc_projector/test_remote.py diff --git a/.strict-typing b/.strict-typing index cfc9d741ca7c..caab7641a89e 100644 --- a/.strict-typing +++ b/.strict-typing @@ -178,6 +178,7 @@ homeassistant.components.iqvia.* homeassistant.components.isy994.* homeassistant.components.jellyfin.* homeassistant.components.jewish_calendar.* +homeassistant.components.jvc_projector.* homeassistant.components.kaleidescape.* homeassistant.components.knx.* homeassistant.components.kraken.* diff --git a/CODEOWNERS b/CODEOWNERS index ca0475f25eed..e3d53d903e6a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -608,6 +608,8 @@ build.json @home-assistant/supervisor /tests/components/juicenet/ @jesserockz /homeassistant/components/justnimbus/ @kvanzuijlen /tests/components/justnimbus/ @kvanzuijlen +/homeassistant/components/jvc_projector/ @SteveEasley +/tests/components/jvc_projector/ @SteveEasley /homeassistant/components/kaiterra/ @Michsior14 /homeassistant/components/kaleidescape/ @SteveEasley /tests/components/kaleidescape/ @SteveEasley diff --git a/homeassistant/components/jvc_projector/__init__.py b/homeassistant/components/jvc_projector/__init__.py new file mode 100644 index 000000000000..996d745a1d5b --- /dev/null +++ b/homeassistant/components/jvc_projector/__init__.py @@ -0,0 +1,65 @@ +"""The jvc_projector integration.""" + +from __future__ import annotations + +from jvcprojector import JvcProjector, JvcProjectorAuthError, JvcProjectorConnectError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + EVENT_HOMEASSISTANT_STOP, + Platform, +) +from homeassistant.core import Event, HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady + +from .const import DOMAIN +from .coordinator import JvcProjectorDataUpdateCoordinator + +PLATFORMS = [Platform.REMOTE] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up integration from a config entry.""" + device = JvcProjector( + host=entry.data[CONF_HOST], + port=entry.data[CONF_PORT], + password=entry.data[CONF_PASSWORD], + ) + + try: + await device.connect(True) + except JvcProjectorConnectError as err: + await device.disconnect() + raise ConfigEntryNotReady( + f"Unable to connect to {entry.data[CONF_HOST]}" + ) from err + except JvcProjectorAuthError as err: + await device.disconnect() + raise ConfigEntryAuthFailed("Password authentication failed") from err + + coordinator = JvcProjectorDataUpdateCoordinator(hass, device) + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + async def disconnect(event: Event) -> None: + await device.disconnect() + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, disconnect) + ) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + await hass.data[DOMAIN][entry.entry_id].device.disconnect() + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok diff --git a/homeassistant/components/jvc_projector/config_flow.py b/homeassistant/components/jvc_projector/config_flow.py new file mode 100644 index 000000000000..181d11e1f562 --- /dev/null +++ b/homeassistant/components/jvc_projector/config_flow.py @@ -0,0 +1,129 @@ +"""Config flow for the jvc_projector integration.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from jvcprojector import JvcProjector, JvcProjectorAuthError, JvcProjectorConnectError +from jvcprojector.projector import DEFAULT_PORT +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.device_registry import format_mac +from homeassistant.util.network import is_host_valid + +from .const import DOMAIN, NAME + + +class JvcProjectorConfigFlow(ConfigFlow, domain=DOMAIN): + """Config flow for the JVC Projector integration.""" + + VERSION = 1 + + _reauth_entry: ConfigEntry | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle user initiated device additions.""" + errors = {} + + if user_input is not None: + host = user_input[CONF_HOST] + port = user_input[CONF_PORT] + password = user_input.get(CONF_PASSWORD) + + try: + if not is_host_valid(host): + raise InvalidHost + + mac = await get_mac_address(host, port, password) + except InvalidHost: + errors["base"] = "invalid_host" + except JvcProjectorConnectError: + errors["base"] = "cannot_connect" + except JvcProjectorAuthError: + errors["base"] = "invalid_auth" + else: + await self.async_set_unique_id(format_mac(mac)) + self._abort_if_unique_id_configured( + updates={CONF_HOST: host, CONF_PORT: port, CONF_PASSWORD: password} + ) + + return self.async_create_entry( + title=NAME, + data={ + CONF_HOST: host, + CONF_PORT: port, + CONF_PASSWORD: password, + }, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT, default=DEFAULT_PORT): int, + vol.Optional(CONF_PASSWORD): str, + } + ), + errors=errors, + ) + + async def async_step_reauth(self, user_input: Mapping[str, Any]) -> FlowResult: + """Perform reauth on password 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: Mapping[str, Any] | None = None + ) -> FlowResult: + """Dialog that informs the user that reauth is required.""" + assert self._reauth_entry + + errors = {} + + if user_input is not None: + host = self._reauth_entry.data[CONF_HOST] + port = self._reauth_entry.data[CONF_PORT] + password = user_input[CONF_PASSWORD] + + try: + await get_mac_address(host, port, password) + except JvcProjectorConnectError: + errors["base"] = "cannot_connect" + except JvcProjectorAuthError: + errors["base"] = "invalid_auth" + else: + self.hass.config_entries.async_update_entry( + self._reauth_entry, + data={CONF_HOST: host, CONF_PORT: port, CONF_PASSWORD: password}, + ) + await self.hass.config_entries.async_reload(self._reauth_entry.entry_id) + return self.async_abort(reason="reauth_successful") + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema({vol.Optional(CONF_PASSWORD): str}), + errors=errors, + ) + + +class InvalidHost(Exception): + """Error indicating invalid network host.""" + + +async def get_mac_address(host: str, port: int, password: str | None) -> str: + """Get device mac address for config flow.""" + device = JvcProjector(host, port=port, password=password) + try: + await device.connect(True) + finally: + await device.disconnect() + return device.mac diff --git a/homeassistant/components/jvc_projector/const.py b/homeassistant/components/jvc_projector/const.py new file mode 100644 index 000000000000..e15aa93bfa5d --- /dev/null +++ b/homeassistant/components/jvc_projector/const.py @@ -0,0 +1,5 @@ +"""Constants for the jvc_projector integration.""" + +NAME = "JVC Projector" +DOMAIN = "jvc_projector" +MANUFACTURER = "JVC" diff --git a/homeassistant/components/jvc_projector/coordinator.py b/homeassistant/components/jvc_projector/coordinator.py new file mode 100644 index 000000000000..a63d68781b34 --- /dev/null +++ b/homeassistant/components/jvc_projector/coordinator.py @@ -0,0 +1,62 @@ +"""Data update coordinator for the jvc_projector integration.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +from jvcprojector import ( + JvcProjector, + JvcProjectorAuthError, + JvcProjectorConnectError, + const, +) + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import NAME + +_LOGGER = logging.getLogger(__name__) + +INTERVAL_SLOW = timedelta(seconds=60) +INTERVAL_FAST = timedelta(seconds=6) + + +class JvcProjectorDataUpdateCoordinator(DataUpdateCoordinator[dict[str, str]]): + """Data update coordinator for the JVC Projector integration.""" + + def __init__(self, hass: HomeAssistant, device: JvcProjector) -> None: + """Initialize the coordinator.""" + super().__init__( + hass=hass, + logger=_LOGGER, + name=NAME, + update_interval=INTERVAL_SLOW, + ) + + self.device = device + self.unique_id = format_mac(device.mac) + + async def _async_update_data(self) -> dict[str, str]: + """Get the latest state data.""" + try: + state = await self.device.get_state() + except JvcProjectorConnectError as err: + raise UpdateFailed(f"Unable to connect to {self.device.host}") from err + except JvcProjectorAuthError as err: + raise ConfigEntryAuthFailed("Password authentication failed") from err + + old_interval = self.update_interval + + if state[const.POWER] != const.STANDBY: + self.update_interval = INTERVAL_FAST + else: + self.update_interval = INTERVAL_SLOW + + if self.update_interval != old_interval: + _LOGGER.debug("Changed update interval to %s", self.update_interval) + + return state diff --git a/homeassistant/components/jvc_projector/entity.py b/homeassistant/components/jvc_projector/entity.py new file mode 100644 index 000000000000..5d1821c6b56f --- /dev/null +++ b/homeassistant/components/jvc_projector/entity.py @@ -0,0 +1,38 @@ +"""Base Entity for the jvc_projector integration.""" + +from __future__ import annotations + +import logging + +from jvcprojector import JvcProjector + +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MANUFACTURER, NAME +from .coordinator import JvcProjectorDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +class JvcProjectorEntity(CoordinatorEntity[JvcProjectorDataUpdateCoordinator]): + """Defines a base JVC Projector entity.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: JvcProjectorDataUpdateCoordinator) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + + self._attr_unique_id = coordinator.unique_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.unique_id)}, + name=NAME, + model=self.device.model, + manufacturer=MANUFACTURER, + ) + + @property + def device(self) -> JvcProjector: + """Return the device representing the projector.""" + return self.coordinator.device diff --git a/homeassistant/components/jvc_projector/manifest.json b/homeassistant/components/jvc_projector/manifest.json new file mode 100644 index 000000000000..bc01da5d89a0 --- /dev/null +++ b/homeassistant/components/jvc_projector/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "jvc_projector", + "name": "JVC Projector", + "codeowners": ["@SteveEasley"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/jvc_projector", + "integration_type": "device", + "iot_class": "local_polling", + "loggers": ["jvcprojector"], + "requirements": ["pyjvcprojector==1.0.6"] +} diff --git a/homeassistant/components/jvc_projector/remote.py b/homeassistant/components/jvc_projector/remote.py new file mode 100644 index 000000000000..e33eef74c487 --- /dev/null +++ b/homeassistant/components/jvc_projector/remote.py @@ -0,0 +1,76 @@ +"""Remote platform for the jvc_projector integration.""" + +from __future__ import annotations + +from collections.abc import Iterable +import logging +from typing import Any + +from jvcprojector import const + +from homeassistant.components.remote import RemoteEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import JvcProjectorEntity + +COMMANDS = { + "menu": const.REMOTE_MENU, + "up": const.REMOTE_UP, + "down": const.REMOTE_DOWN, + "left": const.REMOTE_LEFT, + "right": const.REMOTE_RIGHT, + "ok": const.REMOTE_OK, + "back": const.REMOTE_BACK, + "mpc": const.REMOTE_MPC, + "hide": const.REMOTE_HIDE, + "info": const.REMOTE_INFO, + "input": const.REMOTE_INPUT, + "cmd": const.REMOTE_CMD, + "advanced_menu": const.REMOTE_ADVANCED_MENU, + "picture_mode": const.REMOTE_PICTURE_MODE, + "color_profile": const.REMOTE_COLOR_PROFILE, + "lens_control": const.REMOTE_LENS_CONTROL, + "setting_memory": const.REMOTE_SETTING_MEMORY, + "gamma_settings": const.REMOTE_GAMMA_SETTINGS, +} + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the JVC Projector platform from a config entry.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities([JvcProjectorRemote(coordinator)], True) + + +class JvcProjectorRemote(JvcProjectorEntity, RemoteEntity): + """Representation of a JVC Projector device.""" + + @property + def is_on(self) -> bool: + """Return True if entity is on.""" + return self.coordinator.data["power"] in [const.ON, const.WARMING] + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the device on.""" + await self.device.power_on() + await self.coordinator.async_refresh() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the device off.""" + await self.device.power_off() + await self.coordinator.async_refresh() + + async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None: + """Send a remote command to the device.""" + for cmd in command: + if cmd not in COMMANDS: + raise HomeAssistantError(f"{cmd} is not a known command") + _LOGGER.debug("Sending command '%s'", cmd) + await self.device.remote(COMMANDS[cmd]) diff --git a/homeassistant/components/jvc_projector/strings.json b/homeassistant/components/jvc_projector/strings.json new file mode 100644 index 000000000000..11e2f66f91ea --- /dev/null +++ b/homeassistant/components/jvc_projector/strings.json @@ -0,0 +1,35 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "IP address or hostname of projector", + "port": "IP port of projector (default is 20554)", + "password": "Optional password if projector is configured for one" + } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "Password authentication failed", + "data": { + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "error": { + "invalid_host": "[%key:common::config_flow::error::invalid_host%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "Password authentication failed" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 066fb6fb8b06..9506be28872a 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -214,6 +214,7 @@ FLOWS = { "jellyfin", "juicenet", "justnimbus", + "jvc_projector", "kaleidescape", "keenetic_ndms2", "kegtron", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index b8d8b207991b..22b89430ed61 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2658,6 +2658,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "jvc_projector": { + "name": "JVC Projector", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "kaiterra": { "name": "Kaiterra", "integration_type": "hub", diff --git a/mypy.ini b/mypy.ini index 6f1082aad719..7f8ae23d38d2 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1542,6 +1542,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.jvc_projector.*] +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.kaleidescape.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index e41fe759434b..8022752c6974 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1709,6 +1709,9 @@ pyisy==3.1.14 # homeassistant.components.itach pyitachip2ir==0.0.7 +# homeassistant.components.jvc_projector +pyjvcprojector==1.0.6 + # homeassistant.components.kaleidescape pykaleidescape==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ec099241382c..65a97d8ebec5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1243,6 +1243,9 @@ pyiss==1.0.1 # homeassistant.components.isy994 pyisy==3.1.14 +# homeassistant.components.jvc_projector +pyjvcprojector==1.0.6 + # homeassistant.components.kaleidescape pykaleidescape==1.0.1 diff --git a/tests/components/jvc_projector/__init__.py b/tests/components/jvc_projector/__init__.py new file mode 100644 index 000000000000..d8554e8f4cd7 --- /dev/null +++ b/tests/components/jvc_projector/__init__.py @@ -0,0 +1,7 @@ +"""Tests for JVC Projector integration.""" + +MOCK_HOST = "127.0.0.1" +MOCK_PORT = 20554 +MOCK_PASSWORD = "jvcpasswd" +MOCK_MAC = "jvcmac" +MOCK_MODEL = "jvcmodel" diff --git a/tests/components/jvc_projector/conftest.py b/tests/components/jvc_projector/conftest.py new file mode 100644 index 000000000000..091aad9e849d --- /dev/null +++ b/tests/components/jvc_projector/conftest.py @@ -0,0 +1,58 @@ +"""Fixtures for JVC Projector integration.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.jvc_projector.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT +from homeassistant.core import HomeAssistant + +from . import MOCK_HOST, MOCK_MAC, MOCK_MODEL, MOCK_PASSWORD, MOCK_PORT + +from tests.common import MockConfigEntry + + +@pytest.fixture(name="mock_device") +def fixture_mock_device(request) -> Generator[None, AsyncMock, None]: + """Return a mocked JVC Projector device.""" + target = "homeassistant.components.jvc_projector.JvcProjector" + if hasattr(request, "param"): + target = request.param + + with patch(target, autospec=True) as mock: + device = mock.return_value + device.host = MOCK_HOST + device.port = MOCK_PORT + device.mac = MOCK_MAC + device.model = MOCK_MODEL + device.get_state.return_value = {"power": "standby"} + yield device + + +@pytest.fixture(name="mock_config_entry") +def fixture_mock_config_entry() -> MockConfigEntry: + """Return a mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id=MOCK_MAC, + version=1, + data={ + CONF_HOST: MOCK_HOST, + CONF_PORT: MOCK_PORT, + CONF_PASSWORD: MOCK_PASSWORD, + }, + ) + + +@pytest.fixture(name="mock_integration") +async def fixture_mock_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> MockConfigEntry: + """Return a mock ConfigEntry setup for the integration.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + return mock_config_entry diff --git a/tests/components/jvc_projector/test_config_flow.py b/tests/components/jvc_projector/test_config_flow.py new file mode 100644 index 000000000000..a35dcd1ca386 --- /dev/null +++ b/tests/components/jvc_projector/test_config_flow.py @@ -0,0 +1,297 @@ +"""Tests for JVC Projector config flow.""" + +from unittest.mock import AsyncMock + +from jvcprojector import JvcProjectorAuthError, JvcProjectorConnectError +import pytest + +from homeassistant.components.jvc_projector.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import MOCK_HOST, MOCK_PASSWORD, MOCK_PORT + +from tests.common import MockConfigEntry + +TARGET = "homeassistant.components.jvc_projector.config_flow.JvcProjector" + + +@pytest.mark.parametrize("mock_device", [TARGET], indirect=True) +async def test_user_config_flow_success( + hass: HomeAssistant, mock_device: AsyncMock +) -> None: + """Test user config flow success.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: MOCK_HOST, + CONF_PORT: MOCK_PORT, + CONF_PASSWORD: MOCK_PASSWORD, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert "data" in result + assert result["data"][CONF_HOST] == MOCK_HOST + assert result["data"][CONF_PORT] == MOCK_PORT + assert result["data"][CONF_PASSWORD] == MOCK_PASSWORD + + +@pytest.mark.parametrize("mock_device", [TARGET], indirect=True) +async def test_user_config_flow_bad_connect_errors( + hass: HomeAssistant, mock_device: AsyncMock +) -> None: + """Test errors when connection error occurs.""" + mock_device.connect.side_effect = JvcProjectorConnectError + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT, CONF_PASSWORD: MOCK_PASSWORD}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + # Finish flow with success + + mock_device.connect.side_effect = None + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT, CONF_PASSWORD: MOCK_PASSWORD}, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert "data" in result + assert result["data"][CONF_HOST] == MOCK_HOST + assert result["data"][CONF_PORT] == MOCK_PORT + assert result["data"][CONF_PASSWORD] == MOCK_PASSWORD + + +@pytest.mark.parametrize("mock_device", [TARGET], indirect=True) +async def test_user_config_flow_device_exists_abort( + hass: HomeAssistant, mock_device: AsyncMock, mock_integration: MockConfigEntry +) -> None: + """Test flow aborts when device already configured.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT, CONF_PASSWORD: MOCK_PASSWORD}, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize("mock_device", [TARGET], indirect=True) +async def test_user_config_flow_bad_host_errors( + hass: HomeAssistant, mock_device: AsyncMock +) -> None: + """Test errors when bad host error occurs.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_HOST: "", CONF_PORT: MOCK_PORT, CONF_PASSWORD: MOCK_PASSWORD}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_host"} + + # Finish flow with success + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT, CONF_PASSWORD: MOCK_PASSWORD}, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert "data" in result + assert result["data"][CONF_HOST] == MOCK_HOST + assert result["data"][CONF_PORT] == MOCK_PORT + assert result["data"][CONF_PASSWORD] == MOCK_PASSWORD + + +@pytest.mark.parametrize("mock_device", [TARGET], indirect=True) +async def test_user_config_flow_bad_auth_errors( + hass: HomeAssistant, mock_device: AsyncMock +) -> None: + """Test errors when bad auth error occurs.""" + mock_device.connect.side_effect = JvcProjectorAuthError + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT, CONF_PASSWORD: MOCK_PASSWORD}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_auth"} + + # Finish flow with success + + mock_device.connect.side_effect = None + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT, CONF_PASSWORD: MOCK_PASSWORD}, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert "data" in result + assert result["data"][CONF_HOST] == MOCK_HOST + assert result["data"][CONF_PORT] == MOCK_PORT + assert result["data"][CONF_PASSWORD] == MOCK_PASSWORD + + +@pytest.mark.parametrize("mock_device", [TARGET], indirect=True) +async def test_reauth_config_flow_success( + hass: HomeAssistant, mock_device: AsyncMock, mock_integration: MockConfigEntry +) -> None: + """Test reauth config flow success.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "entry_id": mock_integration.entry_id, + }, + data={CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_PASSWORD: MOCK_PASSWORD} + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + assert mock_integration.data[CONF_HOST] == MOCK_HOST + assert mock_integration.data[CONF_PORT] == MOCK_PORT + assert mock_integration.data[CONF_PASSWORD] == MOCK_PASSWORD + + +@pytest.mark.parametrize("mock_device", [TARGET], indirect=True) +async def test_reauth_config_flow_auth_error( + hass: HomeAssistant, mock_device: AsyncMock, mock_integration: MockConfigEntry +) -> None: + """Test reauth config flow when connect fails.""" + mock_device.connect.side_effect = JvcProjectorAuthError + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "entry_id": mock_integration.entry_id, + }, + data={CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_PASSWORD: MOCK_PASSWORD} + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "invalid_auth"} + + # Finish flow with success + + mock_device.connect.side_effect = None + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "entry_id": mock_integration.entry_id, + }, + data={CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_PASSWORD: MOCK_PASSWORD} + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + assert mock_integration.data[CONF_HOST] == MOCK_HOST + assert mock_integration.data[CONF_PORT] == MOCK_PORT + assert mock_integration.data[CONF_PASSWORD] == MOCK_PASSWORD + + +@pytest.mark.parametrize("mock_device", [TARGET], indirect=True) +async def test_reauth_config_flow_connect_error( + hass: HomeAssistant, mock_device: AsyncMock, mock_integration: MockConfigEntry +) -> None: + """Test reauth config flow when connect fails.""" + mock_device.connect.side_effect = JvcProjectorConnectError + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "entry_id": mock_integration.entry_id, + }, + data={CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_PASSWORD: MOCK_PASSWORD} + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "cannot_connect"} + + # Finish flow with success + + mock_device.connect.side_effect = None + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "entry_id": mock_integration.entry_id, + }, + data={CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_PASSWORD: MOCK_PASSWORD} + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + assert mock_integration.data[CONF_HOST] == MOCK_HOST + assert mock_integration.data[CONF_PORT] == MOCK_PORT + assert mock_integration.data[CONF_PASSWORD] == MOCK_PASSWORD diff --git a/tests/components/jvc_projector/test_coordinator.py b/tests/components/jvc_projector/test_coordinator.py new file mode 100644 index 000000000000..cfda3728eb0e --- /dev/null +++ b/tests/components/jvc_projector/test_coordinator.py @@ -0,0 +1,73 @@ +"""Tests for JVC Projector config entry.""" + +from datetime import timedelta +from unittest.mock import AsyncMock + +from jvcprojector import JvcProjectorAuthError, JvcProjectorConnectError + +from homeassistant.components.jvc_projector import DOMAIN +from homeassistant.components.jvc_projector.coordinator import ( + INTERVAL_FAST, + INTERVAL_SLOW, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.util.dt import utcnow + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_coordinator_update( + hass: HomeAssistant, + mock_device: AsyncMock, + mock_integration: MockConfigEntry, +) -> None: + """Test coordinator update runs.""" + mock_device.get_state.return_value = {"power": "standby"} + async_fire_time_changed( + hass, utcnow() + timedelta(seconds=INTERVAL_SLOW.seconds + 1) + ) + await hass.async_block_till_done() + assert mock_device.get_state.call_count == 3 + coordinator = hass.data[DOMAIN][mock_integration.entry_id] + assert coordinator.update_interval == INTERVAL_SLOW + + +async def test_coordinator_connect_error( + hass: HomeAssistant, + mock_device: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test coordinator connect error.""" + mock_device.get_state.side_effect = JvcProjectorConnectError + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_coordinator_auth_error( + hass: HomeAssistant, + mock_device: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test coordinator auth error.""" + mock_device.get_state.side_effect = JvcProjectorAuthError + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_coordinator_device_on( + hass: HomeAssistant, + mock_device: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test coordinator changes update interval when device is on.""" + mock_device.get_state.return_value = {"power": "on"} + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + coordinator = hass.data[DOMAIN][mock_config_entry.entry_id] + assert coordinator.update_interval == INTERVAL_FAST diff --git a/tests/components/jvc_projector/test_init.py b/tests/components/jvc_projector/test_init.py new file mode 100644 index 000000000000..0f1ef8b6dcf0 --- /dev/null +++ b/tests/components/jvc_projector/test_init.py @@ -0,0 +1,71 @@ +"""Tests for JVC Projector config entry.""" + +from unittest.mock import AsyncMock + +from jvcprojector import JvcProjectorAuthError, JvcProjectorConnectError + +from homeassistant.components.jvc_projector.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import MOCK_MAC + +from tests.common import MockConfigEntry + + +async def test_init( + hass: HomeAssistant, + mock_device: AsyncMock, + mock_integration: MockConfigEntry, +) -> None: + """Test initialization.""" + device_registry = dr.async_get(hass) + device = device_registry.async_get_device(identifiers={(DOMAIN, MOCK_MAC)}) + assert device is not None + assert device.identifiers == {(DOMAIN, MOCK_MAC)} + + +async def test_unload_config_entry( + hass: HomeAssistant, + mock_device: AsyncMock, + mock_integration: MockConfigEntry, +) -> None: + """Test config entry loading and unloading.""" + mock_config_entry = mock_integration + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.entry_id not in hass.data[DOMAIN] + + +async def test_config_entry_connect_error( + hass: HomeAssistant, + mock_device: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test config entry with connect error.""" + mock_device.connect.side_effect = JvcProjectorConnectError + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_config_entry_auth_error( + hass: HomeAssistant, + mock_device: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test config entry with auth error.""" + mock_device.connect.side_effect = JvcProjectorAuthError + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR diff --git a/tests/components/jvc_projector/test_remote.py b/tests/components/jvc_projector/test_remote.py new file mode 100644 index 000000000000..5beccd33e385 --- /dev/null +++ b/tests/components/jvc_projector/test_remote.py @@ -0,0 +1,77 @@ +"""Tests for JVC Projector remote platform.""" + +from unittest.mock import MagicMock + +import pytest + +from homeassistant.components.remote import ( + ATTR_COMMAND, + DOMAIN as REMOTE_DOMAIN, + SERVICE_SEND_COMMAND, +) +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + +ENTITY_ID = "remote.jvc_projector" + + +async def test_entity_state( + hass: HomeAssistant, + mock_device: MagicMock, + mock_integration: MockConfigEntry, +) -> None: + """Tests entity state is registered.""" + entity = hass.states.get(ENTITY_ID) + assert entity + assert er.async_get(hass).async_get(entity.entity_id) + + +async def test_commands( + hass: HomeAssistant, + mock_device: MagicMock, + mock_integration: MockConfigEntry, +) -> None: + """Test service call are called.""" + await hass.services.async_call( + REMOTE_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + assert mock_device.power_on.call_count == 1 + + await hass.services.async_call( + REMOTE_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + assert mock_device.power_off.call_count == 1 + + await hass.services.async_call( + REMOTE_DOMAIN, + SERVICE_SEND_COMMAND, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_COMMAND: ["ok"]}, + blocking=True, + ) + assert mock_device.remote.call_count == 1 + + +async def test_unknown_command( + hass: HomeAssistant, + mock_device: MagicMock, + mock_integration: MockConfigEntry, +) -> None: + """Test unknown service call errors.""" + with pytest.raises(HomeAssistantError) as err: + await hass.services.async_call( + REMOTE_DOMAIN, + SERVICE_SEND_COMMAND, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_COMMAND: ["bad"]}, + blocking=True, + ) + assert str(err.value) == "bad is not a known command" From e597b3a862a08208e22e24bee7bbf082cc8f5a01 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 5 May 2023 20:56:49 +0200 Subject: [PATCH 222/822] Remove issue from skybell (#92602) Remove issue --- homeassistant/components/skybell/__init__.py | 20 ------------------- homeassistant/components/skybell/strings.json | 6 ------ 2 files changed, 26 deletions(-) diff --git a/homeassistant/components/skybell/__init__.py b/homeassistant/components/skybell/__init__.py index 35de1a5b57bf..b2b102ce155b 100644 --- a/homeassistant/components/skybell/__init__.py +++ b/homeassistant/components/skybell/__init__.py @@ -11,8 +11,6 @@ from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType from .const import DOMAIN from .coordinator import SkybellDataUpdateCoordinator @@ -26,24 +24,6 @@ PLATFORMS = [ ] -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the SkyBell component.""" - hass.data.setdefault(DOMAIN, {}) - - if DOMAIN in config: - async_create_issue( - hass, - DOMAIN, - "removed_yaml", - breaks_in_ha_version="2022.9.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="removed_yaml", - ) - - return True - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Skybell from a config entry.""" email = entry.data[CONF_EMAIL] diff --git a/homeassistant/components/skybell/strings.json b/homeassistant/components/skybell/strings.json index f9122a1e1006..4289c3ed3c3a 100644 --- a/homeassistant/components/skybell/strings.json +++ b/homeassistant/components/skybell/strings.json @@ -24,11 +24,5 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } - }, - "issues": { - "removed_yaml": { - "title": "The Skybell YAML configuration has been removed", - "description": "Configuring Skybell using YAML has been removed.\n\nYour existing YAML configuration is not used by Home Assistant.\n\nRemove the YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } } } From 85dcd4007cc022b759ddbc858a460e50179e4982 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 5 May 2023 21:02:40 +0200 Subject: [PATCH 223/822] Remove yaml config issue from steam_online (#92592) Remove yaml config issue from steam --- .../components/steam_online/__init__.py | 18 ------------------ .../components/steam_online/strings.json | 6 ------ 2 files changed, 24 deletions(-) diff --git a/homeassistant/components/steam_online/__init__.py b/homeassistant/components/steam_online/__init__.py index 2b1b32232129..72f1cd2abb85 100644 --- a/homeassistant/components/steam_online/__init__.py +++ b/homeassistant/components/steam_online/__init__.py @@ -4,8 +4,6 @@ from __future__ import annotations from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType from .const import DOMAIN from .coordinator import SteamDataUpdateCoordinator @@ -13,22 +11,6 @@ from .coordinator import SteamDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Steam integration.""" - if DOMAIN in config: - async_create_issue( - hass, - DOMAIN, - "removed_yaml", - breaks_in_ha_version="2022.8.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="removed_yaml", - ) - - return True - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Steam from a config entry.""" coordinator = SteamDataUpdateCoordinator(hass) diff --git a/homeassistant/components/steam_online/strings.json b/homeassistant/components/steam_online/strings.json index 63dc7cce22a9..1b431795ea4e 100644 --- a/homeassistant/components/steam_online/strings.json +++ b/homeassistant/components/steam_online/strings.json @@ -35,11 +35,5 @@ "error": { "unauthorized": "Friends list restricted: Please refer to the documentation on how to see all other friends" } - }, - "issues": { - "removed_yaml": { - "title": "The Steam YAML configuration has been removed", - "description": "Configuring Steam using YAML has been removed.\n\nYour existing YAML configuration is not used by Home Assistant.\n\nRemove the YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } } } From 82b4368d1de40c0454c2482a6e6a5942cf100dce Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 5 May 2023 21:19:16 +0200 Subject: [PATCH 224/822] Bump pytrafikverket to 0.3.1 (#92425) * Bump pytrafikverket 0.3.0 * 0.3.1 * mypy * Fix exceptions --- .../trafikverket_ferry/config_flow.py | 30 ++++++------- .../trafikverket_ferry/coordinator.py | 6 ++- .../trafikverket_ferry/manifest.json | 2 +- .../components/trafikverket_train/__init__.py | 11 +++-- .../trafikverket_train/config_flow.py | 43 +++++++++---------- .../trafikverket_train/manifest.json | 2 +- .../components/trafikverket_train/sensor.py | 15 +++++-- .../config_flow.py | 34 ++++++++------- .../coordinator.py | 10 ++++- .../trafikverket_weatherstation/manifest.json | 2 +- .../trafikverket_weatherstation/sensor.py | 7 ++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../trafikverket_ferry/test_config_flow.py | 25 +++++------ .../trafikverket_ferry/test_coordinator.py | 17 ++++++-- .../trafikverket_train/test_config_flow.py | 33 ++++++++------ .../test_config_flow.py | 19 +++++--- 17 files changed, 154 insertions(+), 106 deletions(-) diff --git a/homeassistant/components/trafikverket_ferry/config_flow.py b/homeassistant/components/trafikverket_ferry/config_flow.py index a1f984e55567..2fb6cfb642ad 100644 --- a/homeassistant/components/trafikverket_ferry/config_flow.py +++ b/homeassistant/components/trafikverket_ferry/config_flow.py @@ -5,6 +5,7 @@ from collections.abc import Mapping from typing import Any from pytrafikverket import TrafikverketFerry +from pytrafikverket.exceptions import InvalidAuthentication, NoFerryFound import voluptuous as vol from homeassistant import config_entries @@ -16,9 +17,6 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CONF_FROM, CONF_TIME, CONF_TO, DOMAIN from .util import create_unique_id -ERROR_INVALID_AUTH = "Source: Security, message: Invalid authentication" -ERROR_INVALID_ROUTE = "No FerryAnnouncement found" - DATA_SCHEMA = vol.Schema( { vol.Required(CONF_API_KEY): selector.TextSelector( @@ -81,13 +79,12 @@ class TVFerryConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): await self.validate_input( api_key, self.entry.data[CONF_FROM], self.entry.data[CONF_TO] ) - except ValueError as err: - if str(err) == ERROR_INVALID_AUTH: - errors["base"] = "invalid_auth" - elif str(err) == ERROR_INVALID_ROUTE: - errors["base"] = "invalid_route" - else: - errors["base"] = "cannot_connect" + except InvalidAuthentication: + errors["base"] = "invalid_auth" + except NoFerryFound: + errors["base"] = "invalid_route" + except Exception: # pylint: disable=broad-exception-caught + errors["base"] = "cannot_connect" else: self.hass.config_entries.async_update_entry( self.entry, @@ -126,13 +123,12 @@ class TVFerryConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): try: await self.validate_input(api_key, ferry_from, ferry_to) - except ValueError as err: - if str(err) == ERROR_INVALID_AUTH: - errors["base"] = "invalid_auth" - elif str(err) == ERROR_INVALID_ROUTE: - errors["base"] = "invalid_route" - else: - errors["base"] = "cannot_connect" + except InvalidAuthentication: + errors["base"] = "invalid_auth" + except NoFerryFound: + errors["base"] = "invalid_route" + except Exception: # pylint: disable=broad-exception-caught + errors["base"] = "cannot_connect" else: if not errors: unique_id = create_unique_id( diff --git a/homeassistant/components/trafikverket_ferry/coordinator.py b/homeassistant/components/trafikverket_ferry/coordinator.py index 7c2c64d49f0c..926b57092a72 100644 --- a/homeassistant/components/trafikverket_ferry/coordinator.py +++ b/homeassistant/components/trafikverket_ferry/coordinator.py @@ -6,11 +6,13 @@ import logging from typing import Any from pytrafikverket import TrafikverketFerry +from pytrafikverket.exceptions import InvalidAuthentication, NoFerryFound from pytrafikverket.trafikverket_ferry import FerryStop from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_WEEKDAY, WEEKDAYS 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 homeassistant.util import dt @@ -82,10 +84,12 @@ class TVDataUpdateCoordinator(DataUpdateCoordinator): ] = await self._ferry_api.async_get_next_ferry_stops( self._from, self._to, when, 3 ) - except ValueError as error: + except NoFerryFound as error: raise UpdateFailed( f"Departure {when} encountered a problem: {error}" ) from error + except InvalidAuthentication as error: + raise ConfigEntryAuthFailed(error) from error states = { "departure_time": routedata[0].departure_time, diff --git a/homeassistant/components/trafikverket_ferry/manifest.json b/homeassistant/components/trafikverket_ferry/manifest.json index 2dabb29c4d73..2221038caaeb 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.2.3"] + "requirements": ["pytrafikverket==0.3.1"] } diff --git a/homeassistant/components/trafikverket_train/__init__.py b/homeassistant/components/trafikverket_train/__init__.py index 0bedb7141c6e..8047cf2046d3 100644 --- a/homeassistant/components/trafikverket_train/__init__.py +++ b/homeassistant/components/trafikverket_train/__init__.py @@ -2,6 +2,11 @@ from __future__ import annotations from pytrafikverket import TrafikverketTrain +from pytrafikverket.exceptions import ( + InvalidAuthentication, + MultipleTrainStationsFound, + NoTrainStationFound, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY @@ -21,9 +26,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: to_station = await train_api.async_get_train_station(entry.data[CONF_TO]) from_station = await train_api.async_get_train_station(entry.data[CONF_FROM]) - except ValueError as error: - if "Invalid authentication" in error.args[0]: - raise ConfigEntryAuthFailed from error + except InvalidAuthentication as error: + raise ConfigEntryAuthFailed from error + except (NoTrainStationFound, MultipleTrainStationsFound) as error: raise ConfigEntryNotReady( f"Problem when trying station {entry.data[CONF_FROM]} to" f" {entry.data[CONF_TO]}. Error: {error} " diff --git a/homeassistant/components/trafikverket_train/config_flow.py b/homeassistant/components/trafikverket_train/config_flow.py index 55db0f982446..fc23d3b953de 100644 --- a/homeassistant/components/trafikverket_train/config_flow.py +++ b/homeassistant/components/trafikverket_train/config_flow.py @@ -5,6 +5,11 @@ from collections.abc import Mapping from typing import Any from pytrafikverket import TrafikverketTrain +from pytrafikverket.exceptions import ( + InvalidAuthentication, + MultipleTrainStationsFound, + NoTrainStationFound, +) import voluptuous as vol from homeassistant import config_entries @@ -23,10 +28,6 @@ import homeassistant.util.dt as dt_util from .const import CONF_FROM, CONF_TIME, CONF_TO, DOMAIN from .util import create_unique_id -ERROR_INVALID_AUTH = "Source: Security, message: Invalid authentication" -ERROR_INVALID_STATION = "Could not find a station with the specified name" -ERROR_MULTIPLE_STATION = "Found multiple stations with the specified name" - DATA_SCHEMA = vol.Schema( { vol.Required(CONF_API_KEY): TextSelector(), @@ -86,15 +87,14 @@ class TVTrainConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): await self.validate_input( api_key, self.entry.data[CONF_FROM], self.entry.data[CONF_TO] ) - except ValueError as err: - if str(err) == ERROR_INVALID_AUTH: - errors["base"] = "invalid_auth" - elif str(err) == ERROR_INVALID_STATION: - errors["base"] = "invalid_station" - elif str(err) == ERROR_MULTIPLE_STATION: - errors["base"] = "more_stations" - else: - errors["base"] = "cannot_connect" + except InvalidAuthentication: + errors["base"] = "invalid_auth" + except NoTrainStationFound: + errors["base"] = "invalid_station" + except MultipleTrainStationsFound: + errors["base"] = "more_stations" + except Exception: # pylint: disable=broad-exception-caught + errors["base"] = "cannot_connect" else: self.hass.config_entries.async_update_entry( self.entry, @@ -131,15 +131,14 @@ class TVTrainConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): try: await self.validate_input(api_key, train_from, train_to) - except ValueError as err: - if str(err) == ERROR_INVALID_AUTH: - errors["base"] = "invalid_auth" - elif str(err) == ERROR_INVALID_STATION: - errors["base"] = "invalid_station" - elif str(err) == ERROR_MULTIPLE_STATION: - errors["base"] = "more_stations" - else: - errors["base"] = "cannot_connect" + except InvalidAuthentication: + errors["base"] = "invalid_auth" + except NoTrainStationFound: + errors["base"] = "invalid_station" + except MultipleTrainStationsFound: + errors["base"] = "more_stations" + except Exception: # pylint: disable=broad-exception-caught + errors["base"] = "cannot_connect" else: if train_time: if bool(dt_util.parse_time(train_time) is None): diff --git a/homeassistant/components/trafikverket_train/manifest.json b/homeassistant/components/trafikverket_train/manifest.json index bdd3dd9ab4a5..7af7c6df761e 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.2.3"] + "requirements": ["pytrafikverket==0.3.1"] } diff --git a/homeassistant/components/trafikverket_train/sensor.py b/homeassistant/components/trafikverket_train/sensor.py index 678a0af2294b..f48127b3e977 100644 --- a/homeassistant/components/trafikverket_train/sensor.py +++ b/homeassistant/components/trafikverket_train/sensor.py @@ -3,9 +3,13 @@ from __future__ import annotations from datetime import date, datetime, time, timedelta import logging -from typing import Any +from typing import TYPE_CHECKING, Any from pytrafikverket import TrafikverketTrain +from pytrafikverket.exceptions import ( + MultipleTrainAnnouncementFound, + NoTrainAnnouncementFound, +) from pytrafikverket.trafikverket_train import StationInfo, TrainStop from homeassistant.components.sensor import SensorDeviceClass, SensorEntity @@ -119,6 +123,8 @@ class TrainSensor(SensorEntity): name=name, configuration_url="https://api.trafikinfo.trafikverket.se/", ) + if TYPE_CHECKING: + assert from_station.name and to_station.name self._attr_unique_id = create_unique_id( from_station.name, to_station.name, departuretime, weekday ) @@ -134,6 +140,7 @@ class TrainSensor(SensorEntity): ) try: if self._time: + _LOGGER.debug("%s, %s, %s", self._from_station, self._to_station, when) _state = await self._train_api.async_get_train_stop( self._from_station, self._to_station, when ) @@ -141,7 +148,7 @@ class TrainSensor(SensorEntity): _state = await self._train_api.async_get_next_train_stop( self._from_station, self._to_station, when ) - except ValueError as error: + except (NoTrainAnnouncementFound, MultipleTrainAnnouncementFound) as error: _LOGGER.error("Departure %s encountered a problem: %s", when, error) if not _state: @@ -153,6 +160,8 @@ class TrainSensor(SensorEntity): self._attr_available = True # The original datetime doesn't provide a timezone so therefore attaching it here. + if TYPE_CHECKING: + assert _state.advertised_time_at_location self._attr_native_value = dt.as_utc(_state.advertised_time_at_location) if _state.time_at_location: self._attr_native_value = dt.as_utc(_state.time_at_location) @@ -165,7 +174,7 @@ class TrainSensor(SensorEntity): """Return extra state attributes.""" attributes: dict[str, Any] = { - ATTR_DEPARTURE_STATE: state.get_state().name, + ATTR_DEPARTURE_STATE: state.get_state().value, ATTR_CANCELED: state.canceled, ATTR_DELAY_TIME: None, ATTR_PLANNED_TIME: None, diff --git a/homeassistant/components/trafikverket_weatherstation/config_flow.py b/homeassistant/components/trafikverket_weatherstation/config_flow.py index 345d625c7c1f..f8f862980453 100644 --- a/homeassistant/components/trafikverket_weatherstation/config_flow.py +++ b/homeassistant/components/trafikverket_weatherstation/config_flow.py @@ -1,6 +1,11 @@ """Adds config flow for Trafikverket Weather integration.""" from __future__ import annotations +from pytrafikverket.exceptions import ( + InvalidAuthentication, + MultipleWeatherStationsFound, + NoWeatherStationFound, +) from pytrafikverket.trafikverket_weather import TrafikverketWeather import voluptuous as vol @@ -20,15 +25,11 @@ class TVWeatherConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): entry: config_entries.ConfigEntry - async def validate_input(self, sensor_api: str, station: str) -> str: + async def validate_input(self, sensor_api: str, station: str) -> None: """Validate input from user input.""" web_session = async_get_clientsession(self.hass) weather_api = TrafikverketWeather(web_session, sensor_api) - try: - await weather_api.async_get_weather(station) - except ValueError as err: - return str(err) - return "connected" + await weather_api.async_get_weather(station) async def async_step_user( self, user_input: dict[str, str] | None = None @@ -41,8 +42,17 @@ class TVWeatherConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): api_key = user_input[CONF_API_KEY] station = user_input[CONF_STATION] - validate = await self.validate_input(api_key, station) - if validate == "connected": + try: + await self.validate_input(api_key, station) + except InvalidAuthentication: + errors["base"] = "invalid_auth" + except NoWeatherStationFound: + errors["base"] = "invalid_station" + except MultipleWeatherStationsFound: + errors["base"] = "more_stations" + except Exception: # pylint: disable=broad-exception-caught + errors["base"] = "cannot_connect" + else: return self.async_create_entry( title=name, data={ @@ -50,14 +60,6 @@ class TVWeatherConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): CONF_STATION: station, }, ) - if validate == "Source: Security, message: Invalid authentication": - errors["base"] = "invalid_auth" - elif validate == "Could not find a weather station with the specified name": - errors["base"] = "invalid_station" - elif validate == "Found multiple weather stations with the specified name": - errors["base"] = "more_stations" - else: - errors["base"] = "cannot_connect" return self.async_show_form( step_id="user", diff --git a/homeassistant/components/trafikverket_weatherstation/coordinator.py b/homeassistant/components/trafikverket_weatherstation/coordinator.py index 990dcc0bc0bb..40c551089d27 100644 --- a/homeassistant/components/trafikverket_weatherstation/coordinator.py +++ b/homeassistant/components/trafikverket_weatherstation/coordinator.py @@ -4,11 +4,17 @@ from __future__ import annotations from datetime import timedelta import logging +from pytrafikverket.exceptions import ( + InvalidAuthentication, + MultipleWeatherStationsFound, + NoWeatherStationFound, +) from pytrafikverket.trafikverket_weather import TrafikverketWeather, WeatherStationInfo from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY 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 @@ -38,6 +44,8 @@ class TVDataUpdateCoordinator(DataUpdateCoordinator[WeatherStationInfo]): """Fetch data from Trafikverket.""" try: weatherdata = await self._weather_api.async_get_weather(self._station) - except ValueError as error: + except InvalidAuthentication as error: + raise ConfigEntryAuthFailed from error + except (NoWeatherStationFound, MultipleWeatherStationsFound) as error: raise UpdateFailed from error return weatherdata diff --git a/homeassistant/components/trafikverket_weatherstation/manifest.json b/homeassistant/components/trafikverket_weatherstation/manifest.json index 9b7dad395f90..3d584e809985 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.2.3"] + "requirements": ["pytrafikverket==0.3.1"] } diff --git a/homeassistant/components/trafikverket_weatherstation/sensor.py b/homeassistant/components/trafikverket_weatherstation/sensor.py index c8eed559f7c4..6dfd6a5e7c40 100644 --- a/homeassistant/components/trafikverket_weatherstation/sensor.py +++ b/homeassistant/components/trafikverket_weatherstation/sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass from datetime import datetime +from typing import TYPE_CHECKING from homeassistant.components.sensor import ( SensorDeviceClass, @@ -190,7 +191,9 @@ class TrafikverketWeatherStation( def native_value(self) -> StateType | datetime: """Return state of sensor.""" if self.entity_description.api_key == "measure_time": - return _to_datetime(self.coordinator.data.measure_time) + if TYPE_CHECKING: + assert self.coordinator.data.measure_time + return self.coordinator.data.measure_time state: StateType = getattr( self.coordinator.data, self.entity_description.api_key @@ -204,4 +207,6 @@ class TrafikverketWeatherStation( @property def available(self) -> bool: """Return if entity is available.""" + if TYPE_CHECKING: + assert self.coordinator.data.active return self.coordinator.data.active and super().available diff --git a/requirements_all.txt b/requirements_all.txt index 8022752c6974..95504b93ea57 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2146,7 +2146,7 @@ pytradfri[async]==9.0.1 # homeassistant.components.trafikverket_ferry # homeassistant.components.trafikverket_train # homeassistant.components.trafikverket_weatherstation -pytrafikverket==0.2.3 +pytrafikverket==0.3.1 # homeassistant.components.usb pyudev==0.23.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 65a97d8ebec5..9364c3e66fd7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1551,7 +1551,7 @@ pytradfri[async]==9.0.1 # homeassistant.components.trafikverket_ferry # homeassistant.components.trafikverket_train # homeassistant.components.trafikverket_weatherstation -pytrafikverket==0.2.3 +pytrafikverket==0.3.1 # homeassistant.components.usb pyudev==0.23.2 diff --git a/tests/components/trafikverket_ferry/test_config_flow.py b/tests/components/trafikverket_ferry/test_config_flow.py index 7bfb175220e6..dbbd1fb09ac1 100644 --- a/tests/components/trafikverket_ferry/test_config_flow.py +++ b/tests/components/trafikverket_ferry/test_config_flow.py @@ -4,6 +4,7 @@ from __future__ import annotations from unittest.mock import patch import pytest +from pytrafikverket.exceptions import InvalidAuthentication, NoFerryFound from homeassistant import config_entries from homeassistant.components.trafikverket_ferry.const import ( @@ -63,24 +64,24 @@ async def test_form(hass: HomeAssistant) -> None: @pytest.mark.parametrize( - ("error_message", "base_error"), + ("side_effect", "base_error"), [ ( - "Source: Security, message: Invalid authentication", + InvalidAuthentication, "invalid_auth", ), ( - "No FerryAnnouncement found", + NoFerryFound, "invalid_route", ), ( - "Unknown", + Exception, "cannot_connect", ), ], ) async def test_flow_fails( - hass: HomeAssistant, error_message: str, base_error: str + hass: HomeAssistant, side_effect: str, base_error: str ) -> None: """Test config flow errors.""" result4 = await hass.config_entries.flow.async_init( @@ -92,7 +93,7 @@ async def test_flow_fails( with patch( "homeassistant.components.trafikverket_ferry.config_flow.TrafikverketFerry.async_get_next_ferry_stop", - side_effect=ValueError(error_message), + side_effect=side_effect(), ): result4 = await hass.config_entries.flow.async_configure( result4["flow_id"], @@ -161,24 +162,24 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: @pytest.mark.parametrize( - ("sideeffect", "p_error"), + ("side_effect", "p_error"), [ ( - ValueError("Source: Security, message: Invalid authentication"), + InvalidAuthentication, "invalid_auth", ), ( - ValueError("No FerryAnnouncement found"), + NoFerryFound, "invalid_route", ), ( - ValueError("Unknown"), + Exception, "cannot_connect", ), ], ) async def test_reauth_flow_error( - hass: HomeAssistant, sideeffect: Exception, p_error: str + hass: HomeAssistant, side_effect: Exception, p_error: str ) -> None: """Test a reauthentication flow with error.""" entry = MockConfigEntry( @@ -207,7 +208,7 @@ async def test_reauth_flow_error( with patch( "homeassistant.components.trafikverket_ferry.config_flow.TrafikverketFerry.async_get_next_ferry_stop", - side_effect=sideeffect, + side_effect=side_effect(), ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], diff --git a/tests/components/trafikverket_ferry/test_coordinator.py b/tests/components/trafikverket_ferry/test_coordinator.py index 7714e0c38f6a..61c72b82a3f7 100644 --- a/tests/components/trafikverket_ferry/test_coordinator.py +++ b/tests/components/trafikverket_ferry/test_coordinator.py @@ -2,10 +2,11 @@ from __future__ import annotations from datetime import date, datetime, timedelta -from unittest.mock import AsyncMock, patch +from unittest.mock import patch from freezegun.api import FrozenDateTimeFactory import pytest +from pytrafikverket.exceptions import InvalidAuthentication, NoFerryFound from pytrafikverket.trafikverket_ferry import FerryStop from homeassistant.components.trafikverket_ferry.const import DOMAIN @@ -22,7 +23,7 @@ from tests.common import MockConfigEntry, async_fire_time_changed async def test_coordinator( hass: HomeAssistant, - entity_registry_enabled_by_default: AsyncMock, + entity_registry_enabled_by_default: None, monkeypatch: pytest.MonkeyPatch, get_ferries: list[FerryStop], ) -> None: @@ -69,7 +70,7 @@ async def test_coordinator( assert state3.state == str(dt.now().year + 2) + "-05-01T12:00:00+00:00" mock_data.reset_mock() - mock_data.side_effect = ValueError("info") + mock_data.side_effect = NoFerryFound() async_fire_time_changed(hass, dt.utcnow() + timedelta(minutes=6)) await hass.async_block_till_done() mock_data.assert_called_once() @@ -81,11 +82,19 @@ async def test_coordinator( mock_data.side_effect = None async_fire_time_changed(hass, dt.utcnow() + timedelta(minutes=6)) await hass.async_block_till_done() - mock_data.assert_called_once() + # mock_data.assert_called_once() state1 = hass.states.get("sensor.harbor1_departure_from") assert state1.state == "Harbor 1" mock_data.reset_mock() + mock_data.side_effect = InvalidAuthentication() + async_fire_time_changed(hass, dt.utcnow() + timedelta(minutes=6)) + await hass.async_block_till_done() + mock_data.assert_called_once() + state1 = hass.states.get("sensor.harbor1_departure_from") + assert state1.state == STATE_UNAVAILABLE + mock_data.reset_mock() + async def test_coordinator_next_departuredate(freezer: FrozenDateTimeFactory) -> None: """Test the Trafikverket Ferry next_departuredate calculation.""" diff --git a/tests/components/trafikverket_train/test_config_flow.py b/tests/components/trafikverket_train/test_config_flow.py index 0169332609a1..424e1d74162a 100644 --- a/tests/components/trafikverket_train/test_config_flow.py +++ b/tests/components/trafikverket_train/test_config_flow.py @@ -4,6 +4,11 @@ from __future__ import annotations from unittest.mock import patch import pytest +from pytrafikverket.exceptions import ( + InvalidAuthentication, + MultipleTrainStationsFound, + NoTrainStationFound, +) from homeassistant import config_entries from homeassistant.components.trafikverket_train.const import ( @@ -108,28 +113,28 @@ async def test_form_entry_already_exist(hass: HomeAssistant) -> None: @pytest.mark.parametrize( - ("error_message", "base_error"), + ("side_effect", "base_error"), [ ( - "Source: Security, message: Invalid authentication", + InvalidAuthentication, "invalid_auth", ), ( - "Could not find a station with the specified name", + NoTrainStationFound, "invalid_station", ), ( - "Found multiple stations with the specified name", + MultipleTrainStationsFound, "more_stations", ), ( - "Unknown", + Exception, "cannot_connect", ), ], ) async def test_flow_fails( - hass: HomeAssistant, error_message: str, base_error: str + hass: HomeAssistant, side_effect: Exception, base_error: str ) -> None: """Test config flow errors.""" result4 = await hass.config_entries.flow.async_init( @@ -141,7 +146,7 @@ async def test_flow_fails( with patch( "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_station", - side_effect=ValueError(error_message), + side_effect=side_effect(), ): result4 = await hass.config_entries.flow.async_configure( result4["flow_id"], @@ -234,28 +239,28 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: @pytest.mark.parametrize( - ("sideeffect", "p_error"), + ("side_effect", "p_error"), [ ( - ValueError("Source: Security, message: Invalid authentication"), + InvalidAuthentication, "invalid_auth", ), ( - ValueError("Could not find a station with the specified name"), + NoTrainStationFound, "invalid_station", ), ( - ValueError("Found multiple stations with the specified name"), + MultipleTrainStationsFound, "more_stations", ), ( - ValueError("Unknown"), + Exception, "cannot_connect", ), ], ) async def test_reauth_flow_error( - hass: HomeAssistant, sideeffect: Exception, p_error: str + hass: HomeAssistant, side_effect: Exception, p_error: str ) -> None: """Test a reauthentication flow with error.""" entry = MockConfigEntry( @@ -284,7 +289,7 @@ async def test_reauth_flow_error( with patch( "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_station", - side_effect=sideeffect, + side_effect=side_effect(), ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], diff --git a/tests/components/trafikverket_weatherstation/test_config_flow.py b/tests/components/trafikverket_weatherstation/test_config_flow.py index ed0ff9bb1199..36c30b33b53c 100644 --- a/tests/components/trafikverket_weatherstation/test_config_flow.py +++ b/tests/components/trafikverket_weatherstation/test_config_flow.py @@ -4,6 +4,11 @@ from __future__ import annotations from unittest.mock import patch import pytest +from pytrafikverket.exceptions import ( + InvalidAuthentication, + MultipleWeatherStationsFound, + NoWeatherStationFound, +) from homeassistant import config_entries from homeassistant.const import CONF_API_KEY @@ -48,28 +53,28 @@ async def test_form(hass: HomeAssistant) -> None: @pytest.mark.parametrize( - ("error_message", "base_error"), + ("side_effect", "base_error"), [ ( - "Source: Security, message: Invalid authentication", + InvalidAuthentication, "invalid_auth", ), ( - "Could not find a weather station with the specified name", + NoWeatherStationFound, "invalid_station", ), ( - "Found multiple weather stations with the specified name", + MultipleWeatherStationsFound, "more_stations", ), ( - "Unknown", + Exception, "cannot_connect", ), ], ) async def test_flow_fails( - hass: HomeAssistant, error_message: str, base_error: str + hass: HomeAssistant, side_effect: Exception, base_error: str ) -> None: """Test config flow errors.""" result4 = await hass.config_entries.flow.async_init( @@ -81,7 +86,7 @@ async def test_flow_fails( with patch( "homeassistant.components.trafikverket_weatherstation.config_flow.TrafikverketWeather.async_get_weather", - side_effect=ValueError(error_message), + side_effect=side_effect(), ): result4 = await hass.config_entries.flow.async_configure( result4["flow_id"], From f326dd919b65b9582fa31ca2a81b6fe95029000c Mon Sep 17 00:00:00 2001 From: Luke Date: Fri, 5 May 2023 15:20:30 -0400 Subject: [PATCH 225/822] Roborock continue on failed mqtt disconnect (#92502) continue on async disconnect failure --- homeassistant/components/roborock/__init__.py | 6 ++++- tests/components/roborock/mock_data.py | 5 ++++ tests/components/roborock/test_init.py | 23 +++++++++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index 497d30b41cf6..1ea5e4734bb6 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -8,6 +8,7 @@ import logging from roborock.api import RoborockApiClient from roborock.cloud_api import RoborockMqttClient from roborock.containers import HomeDataDevice, RoborockDeviceInfo, UserData +from roborock.exceptions import RoborockException from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_USERNAME @@ -44,7 +45,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: for device, result in zip(devices, network_results) if result is not None } - await mqtt_client.async_disconnect() + try: + await mqtt_client.async_disconnect() + except RoborockException as err: + _LOGGER.warning("Failed disconnecting from the mqtt server %s", err) if not network_info: raise ConfigEntryNotReady( "Could not get network information about your devices" diff --git a/tests/components/roborock/mock_data.py b/tests/components/roborock/mock_data.py index cbd5ef379e87..55eb80868425 100644 --- a/tests/components/roborock/mock_data.py +++ b/tests/components/roborock/mock_data.py @@ -7,6 +7,7 @@ from roborock.containers import ( Consumable, DNDTimer, HomeData, + NetworkInfo, Status, UserData, ) @@ -368,3 +369,7 @@ STATUS = Status.from_dict( ) PROP = DeviceProp(STATUS, DND_TIMER, CLEAN_SUMMARY, CONSUMABLE, CLEAN_RECORD) + +NETWORK_INFO = NetworkInfo( + ip="123.232.12.1", ssid="wifi", mac="ac:cc:cc:cc:cc", bssid="bssid", rssi=90 +) diff --git a/tests/components/roborock/test_init.py b/tests/components/roborock/test_init.py index 05bf08484758..18d9ee1bafeb 100644 --- a/tests/components/roborock/test_init.py +++ b/tests/components/roborock/test_init.py @@ -1,6 +1,8 @@ """Test for Roborock init.""" from unittest.mock import patch +from roborock.exceptions import RoborockTimeout + from homeassistant.components.roborock.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -8,6 +10,7 @@ from homeassistant.helpers.update_coordinator import UpdateFailed from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry +from tests.components.roborock.mock_data import HOME_DATA, NETWORK_INFO async def test_unload_entry( @@ -38,3 +41,23 @@ async def test_config_entry_not_ready( ): await async_setup_component(hass, DOMAIN, {}) assert mock_roborock_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_continue_setup_mqtt_disconnect_fail( + hass: HomeAssistant, mock_roborock_entry: MockConfigEntry +): + """Test that if disconnect fails, we still continue setting up.""" + 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.RoborockMqttClient.async_disconnect", + side_effect=RoborockTimeout(), + ), patch( + "homeassistant.components.roborock.RoborockDataUpdateCoordinator.async_config_entry_first_refresh" + ): + await async_setup_component(hass, DOMAIN, {}) + assert mock_roborock_entry.state is ConfigEntryState.LOADED From 7a57435e8c98cd8f4d8ba2e9a64af563028d74d1 Mon Sep 17 00:00:00 2001 From: mkmer Date: Fri, 5 May 2023 15:21:22 -0400 Subject: [PATCH 226/822] Address connection error wording in Honeywell (#92488) Connection error wording in Honeywell --- homeassistant/components/honeywell/__init__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/honeywell/__init__.py b/homeassistant/components/honeywell/__init__.py index ff5448822894..f5cce1d890a3 100644 --- a/homeassistant/components/honeywell/__init__.py +++ b/homeassistant/components/honeywell/__init__.py @@ -67,8 +67,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b asyncio.TimeoutError, ) as ex: raise ConfigEntryNotReady( - "Failed to initialize the Honeywell client: " - "Connection error: maybe you have exceeded the API rate limit?" + "Failed to initialize the Honeywell client: Connection error" ) from ex loc_id = config_entry.data.get(CONF_LOC_ID) From 774f1c8ef9c7b7e786b6b1e309f8a05af4585da6 Mon Sep 17 00:00:00 2001 From: mkmer Date: Fri, 5 May 2023 15:22:55 -0400 Subject: [PATCH 227/822] Dont reload integration on auth error in Aladdin Connect (#92399) Remove reload on auth error --- homeassistant/components/aladdin_connect/cover.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/homeassistant/components/aladdin_connect/cover.py b/homeassistant/components/aladdin_connect/cover.py index 2cf526e5626b..32eb34333c9b 100644 --- a/homeassistant/components/aladdin_connect/cover.py +++ b/homeassistant/components/aladdin_connect/cover.py @@ -89,15 +89,9 @@ class AladdinDevice(CoverEntity): await self._acc.get_doors(self._serial) self._attr_available = True - except session_manager.ConnectionError: + except (session_manager.ConnectionError, session_manager.InvalidPasswordError): self._attr_available = False - except session_manager.InvalidPasswordError: - self._attr_available = False - await self.hass.async_create_task( - self.hass.config_entries.async_reload(self._entry_id) - ) - @property def is_closed(self) -> bool | None: """Update is closed attribute.""" From 2b3f7ad70d99df897cdd0cf7ca7348066ded5eef Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Fri, 5 May 2023 21:24:42 +0200 Subject: [PATCH 228/822] Add diagnostics to devolo Home Control (#86069) * Add diagnostics to devolo Home Control * Apply feedback --- .../devolo_home_control/diagnostics.py | 49 ++++++++++++++ tests/components/devolo_home_control/mocks.py | 5 ++ .../devolo_home_control/test_diagnostics.py | 65 +++++++++++++++++++ 3 files changed, 119 insertions(+) create mode 100644 homeassistant/components/devolo_home_control/diagnostics.py create mode 100644 tests/components/devolo_home_control/test_diagnostics.py diff --git a/homeassistant/components/devolo_home_control/diagnostics.py b/homeassistant/components/devolo_home_control/diagnostics.py new file mode 100644 index 000000000000..412effcd5ed2 --- /dev/null +++ b/homeassistant/components/devolo_home_control/diagnostics.py @@ -0,0 +1,49 @@ +"""Diagnostics support for devolo Home Control.""" +from __future__ import annotations + +from typing import Any + +from devolo_home_control_api.homecontrol import HomeControl + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from .const import DOMAIN + +TO_REDACT = {CONF_PASSWORD, CONF_USERNAME} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + gateways: list[HomeControl] = hass.data[DOMAIN][entry.entry_id]["gateways"] + + device_info = [] + for gateway in gateways: + device_info.append( + { + "gateway": { + "local_connection": gateway.gateway.local_connection, + "firmware_version": gateway.gateway.firmware_version, + }, + "devices": [ + { + "device_id": device_id, + "device_model_uid": properties.device_model_uid, + "device_type": properties.device_type, + "name": properties.name, + } + for device_id, properties in gateway.devices.items() + ], + } + ) + + diag_data = { + "entry": async_redact_data(entry.as_dict(), TO_REDACT), + "device_info": device_info, + } + + return diag_data diff --git a/tests/components/devolo_home_control/mocks.py b/tests/components/devolo_home_control/mocks.py index aef687936e4f..61a9f1c7d8c2 100644 --- a/tests/components/devolo_home_control/mocks.py +++ b/tests/components/devolo_home_control/mocks.py @@ -115,6 +115,8 @@ class DeviceMock(Zwave): self.brand = "devolo" self.name = "Test Device" self.uid = "Test" + self.device_model_uid = "Test" + self.device_type = "Test" self.settings_property = {"general_device_settings": SettingsMock()} self.href = "https://www.mydevolo.com" @@ -252,6 +254,9 @@ class HomeControlMock(HomeControl): """Initialize the mock.""" self.devices = {} self.publisher = MagicMock() + self.gateway = MagicMock() + self.gateway.local_connection = True + self.gateway.firmware_version = "8.94.0" def websocket_disconnect(self, event: str = "") -> None: """Mock disconnect of the websocket.""" diff --git a/tests/components/devolo_home_control/test_diagnostics.py b/tests/components/devolo_home_control/test_diagnostics.py new file mode 100644 index 000000000000..00c582ebf920 --- /dev/null +++ b/tests/components/devolo_home_control/test_diagnostics.py @@ -0,0 +1,65 @@ +"""Tests for the devolo Home Control diagnostics.""" +from __future__ import annotations + +from unittest.mock import patch + +from aiohttp import ClientSession + +from homeassistant.components.devolo_home_control.diagnostics import TO_REDACT +from homeassistant.components.diagnostics import REDACTED +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import configure_integration +from .mocks import HomeControlMock, HomeControlMockBinarySensor + +from tests.components.diagnostics import get_diagnostics_for_config_entry + + +async def test_entry_diagnostics(hass: HomeAssistant, hass_client: ClientSession): + """Test setup and state change of a climate device.""" + entry = configure_integration(hass) + gateway_1 = HomeControlMockBinarySensor() + gateway_2 = HomeControlMock() + with patch( + "homeassistant.components.devolo_home_control.HomeControl", + side_effect=[gateway_1, gateway_2], + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state == ConfigEntryState.LOADED + + entry_dict = entry.as_dict() + for key in TO_REDACT: + entry_dict["data"][key] = REDACTED + + result = await get_diagnostics_for_config_entry(hass, hass_client, entry) + + assert result == { + "entry": entry_dict, + "device_info": [ + { + "gateway": { + "local_connection": gateway_1.gateway.local_connection, + "firmware_version": gateway_1.gateway.firmware_version, + }, + "devices": [ + { + "device_id": device_id, + "device_model_uid": properties.device_model_uid, + "device_type": properties.device_type, + "name": properties.name, + } + for device_id, properties in gateway_1.devices.items() + ], + }, + { + "gateway": { + "local_connection": gateway_2.gateway.local_connection, + "firmware_version": gateway_2.gateway.firmware_version, + }, + "devices": [], + }, + ], + } From bdd786b1f0eb46f9617c6eb4a5db3b69e1dd8611 Mon Sep 17 00:00:00 2001 From: Tom Harris Date: Fri, 5 May 2023 15:32:51 -0400 Subject: [PATCH 229/822] Cleanup Insteon to use Platform vs DOMAIN constants (#92366) * Utilize Platform constant * Use Platform constant --- homeassistant/components/insteon/__init__.py | 7 +- .../components/insteon/binary_sensor.py | 6 +- homeassistant/components/insteon/climate.py | 7 +- homeassistant/components/insteon/const.py | 1 - homeassistant/components/insteon/cover.py | 6 +- homeassistant/components/insteon/fan.py | 11 +- homeassistant/components/insteon/ipdb.py | 111 ++++++++---------- homeassistant/components/insteon/light.py | 16 +-- homeassistant/components/insteon/lock.py | 7 +- homeassistant/components/insteon/switch.py | 11 +- homeassistant/components/insteon/utils.py | 13 +- 11 files changed, 86 insertions(+), 110 deletions(-) diff --git a/homeassistant/components/insteon/__init__.py b/homeassistant/components/insteon/__init__.py index 3105214bad4a..1667f5fb779a 100644 --- a/homeassistant/components/insteon/__init__.py +++ b/homeassistant/components/insteon/__init__.py @@ -24,11 +24,10 @@ from .const import ( CONF_X10, DOMAIN, INSTEON_PLATFORMS, - ON_OFF_EVENTS, ) from .schemas import convert_yaml_to_config_flow from .utils import ( - add_on_off_event_device, + add_insteon_events, async_register_services, get_device_platforms, register_new_device_callback, @@ -159,8 +158,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: for address in devices: device = devices[address] platforms = get_device_platforms(device) - if ON_OFF_EVENTS in platforms: - add_on_off_event_device(hass, device) + add_insteon_events(hass, device) + if not platforms: create_insteon_device(hass, device, entry.entry_id) _LOGGER.debug("Insteon device count: %s", len(devices)) diff --git a/homeassistant/components/insteon/binary_sensor.py b/homeassistant/components/insteon/binary_sensor.py index 1357cbaed5b4..9d1ec352bedc 100644 --- a/homeassistant/components/insteon/binary_sensor.py +++ b/homeassistant/components/insteon/binary_sensor.py @@ -14,11 +14,11 @@ from pyinsteon.groups import ( ) from homeassistant.components.binary_sensor import ( - DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass, BinarySensorEntity, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -54,13 +54,13 @@ async def async_setup_entry( """Add the Insteon entities for the platform.""" async_add_insteon_entities( hass, - BINARY_SENSOR_DOMAIN, + Platform.BINARY_SENSOR, InsteonBinarySensorEntity, async_add_entities, discovery_info, ) - signal = f"{SIGNAL_ADD_ENTITIES}_{BINARY_SENSOR_DOMAIN}" + signal = f"{SIGNAL_ADD_ENTITIES}_{Platform.BINARY_SENSOR}" async_dispatcher_connect(hass, signal, async_add_insteon_binary_sensor_entities) async_add_insteon_binary_sensor_entities() diff --git a/homeassistant/components/insteon/climate.py b/homeassistant/components/insteon/climate.py index f88dadf12234..cf5f4ac2c0cb 100644 --- a/homeassistant/components/insteon/climate.py +++ b/homeassistant/components/insteon/climate.py @@ -9,7 +9,6 @@ from pyinsteon.constants import ThermostatMode from homeassistant.components.climate import ( ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, - DOMAIN as CLIMATE_DOMAIN, FAN_AUTO, ClimateEntity, ClimateEntityFeature, @@ -17,7 +16,7 @@ from homeassistant.components.climate import ( HVACMode, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.const import ATTR_TEMPERATURE, Platform, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -64,13 +63,13 @@ async def async_setup_entry( """Add the Insteon entities for the platform.""" async_add_insteon_entities( hass, - CLIMATE_DOMAIN, + Platform.CLIMATE, InsteonClimateEntity, async_add_entities, discovery_info, ) - signal = f"{SIGNAL_ADD_ENTITIES}_{CLIMATE_DOMAIN}" + signal = f"{SIGNAL_ADD_ENTITIES}_{Platform.CLIMATE}" async_dispatcher_connect(hass, signal, async_add_insteon_climate_entities) async_add_insteon_climate_entities() diff --git a/homeassistant/components/insteon/const.py b/homeassistant/components/insteon/const.py index 5337ccd36c38..690401995898 100644 --- a/homeassistant/components/insteon/const.py +++ b/homeassistant/components/insteon/const.py @@ -130,7 +130,6 @@ EVENT_GROUP_OFF = "insteon.button_off" EVENT_GROUP_ON_FAST = "insteon.button_on_fast" EVENT_GROUP_OFF_FAST = "insteon.button_off_fast" EVENT_CONF_BUTTON = "button" -ON_OFF_EVENTS = "on_off_events" STATE_NAME_LABEL_MAP = { DIMMABLE_LIGHT_MAIN: "Main", diff --git a/homeassistant/components/insteon/cover.py b/homeassistant/components/insteon/cover.py index 645450166b9f..69a66d304ced 100644 --- a/homeassistant/components/insteon/cover.py +++ b/homeassistant/components/insteon/cover.py @@ -4,11 +4,11 @@ from typing import Any from homeassistant.components.cover import ( ATTR_POSITION, - DOMAIN as COVER_DOMAIN, CoverEntity, CoverEntityFeature, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -29,10 +29,10 @@ async def async_setup_entry( def async_add_insteon_cover_entities(discovery_info=None): """Add the Insteon entities for the platform.""" async_add_insteon_entities( - hass, COVER_DOMAIN, InsteonCoverEntity, async_add_entities, discovery_info + hass, Platform.COVER, InsteonCoverEntity, async_add_entities, discovery_info ) - signal = f"{SIGNAL_ADD_ENTITIES}_{COVER_DOMAIN}" + signal = f"{SIGNAL_ADD_ENTITIES}_{Platform.COVER}" async_dispatcher_connect(hass, signal, async_add_insteon_cover_entities) async_add_insteon_cover_entities() diff --git a/homeassistant/components/insteon/fan.py b/homeassistant/components/insteon/fan.py index c7512ba0278b..b0d664a821b8 100644 --- a/homeassistant/components/insteon/fan.py +++ b/homeassistant/components/insteon/fan.py @@ -4,12 +4,9 @@ from __future__ import annotations import math from typing import Any -from homeassistant.components.fan import ( - DOMAIN as FAN_DOMAIN, - FanEntity, - FanEntityFeature, -) +from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -36,10 +33,10 @@ async def async_setup_entry( def async_add_insteon_fan_entities(discovery_info=None): """Add the Insteon entities for the platform.""" async_add_insteon_entities( - hass, FAN_DOMAIN, InsteonFanEntity, async_add_entities, discovery_info + hass, Platform.FAN, InsteonFanEntity, async_add_entities, discovery_info ) - signal = f"{SIGNAL_ADD_ENTITIES}_{FAN_DOMAIN}" + signal = f"{SIGNAL_ADD_ENTITIES}_{Platform.FAN}" async_dispatcher_connect(hass, signal, async_add_insteon_fan_entities) async_add_insteon_fan_entities() diff --git a/homeassistant/components/insteon/ipdb.py b/homeassistant/components/insteon/ipdb.py index fea1262bffdf..ee799e103f92 100644 --- a/homeassistant/components/insteon/ipdb.py +++ b/homeassistant/components/insteon/ipdb.py @@ -17,11 +17,6 @@ from pyinsteon.device_types.ipdb import ( DimmableLightingControl_SwitchLinc02, DimmableLightingControl_ToggleLinc, EnergyManagement_LoadController, - GeneralController_ControlLinc, - GeneralController_MiniRemote_4, - GeneralController_MiniRemote_8, - GeneralController_MiniRemote_Switch, - GeneralController_RemoteLinc, SecurityHealthSafety_DoorSensor, SecurityHealthSafety_LeakSensor, SecurityHealthSafety_MotionSensor, @@ -47,76 +42,62 @@ from pyinsteon.device_types.ipdb import ( X10OnOffSensor, ) -from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR -from homeassistant.components.climate import DOMAIN as CLIMATE -from homeassistant.components.cover import DOMAIN as COVER -from homeassistant.components.fan import DOMAIN as FAN -from homeassistant.components.light import DOMAIN as LIGHT -from homeassistant.components.lock import DOMAIN as LOCK -from homeassistant.components.switch import DOMAIN as SWITCH - -from .const import ON_OFF_EVENTS +from homeassistant.const import Platform DEVICE_PLATFORM = { - AccessControl_Morningstar: {LOCK: [1]}, - DimmableLightingControl: {LIGHT: [1], ON_OFF_EVENTS: [1]}, - DimmableLightingControl_Dial: {LIGHT: [1], ON_OFF_EVENTS: [1]}, - DimmableLightingControl_DinRail: {LIGHT: [1], ON_OFF_EVENTS: [1]}, - DimmableLightingControl_FanLinc: {LIGHT: [1], FAN: [2], ON_OFF_EVENTS: [1, 2]}, - DimmableLightingControl_InLineLinc01: {LIGHT: [1], ON_OFF_EVENTS: [1]}, - DimmableLightingControl_InLineLinc02: {LIGHT: [1], ON_OFF_EVENTS: [1]}, + AccessControl_Morningstar: {Platform.LOCK: [1]}, + DimmableLightingControl: {Platform.LIGHT: [1]}, + DimmableLightingControl_Dial: {Platform.LIGHT: [1]}, + DimmableLightingControl_DinRail: {Platform.LIGHT: [1]}, + DimmableLightingControl_FanLinc: {Platform.LIGHT: [1], Platform.FAN: [2]}, + DimmableLightingControl_InLineLinc01: {Platform.LIGHT: [1]}, + DimmableLightingControl_InLineLinc02: {Platform.LIGHT: [1]}, DimmableLightingControl_KeypadLinc_6: { - LIGHT: [1], - SWITCH: [3, 4, 5, 6], - ON_OFF_EVENTS: [1, 3, 4, 5, 6], + Platform.LIGHT: [1], + Platform.SWITCH: [3, 4, 5, 6], }, DimmableLightingControl_KeypadLinc_8: { - LIGHT: [1], - SWITCH: range(2, 9), - ON_OFF_EVENTS: range(1, 9), + Platform.LIGHT: [1], + Platform.SWITCH: range(2, 9), }, - DimmableLightingControl_LampLinc: {LIGHT: [1], ON_OFF_EVENTS: [1]}, - DimmableLightingControl_OutletLinc: {LIGHT: [1], ON_OFF_EVENTS: [1]}, - DimmableLightingControl_SwitchLinc01: {LIGHT: [1], ON_OFF_EVENTS: [1]}, - DimmableLightingControl_SwitchLinc02: {LIGHT: [1], ON_OFF_EVENTS: [1]}, - DimmableLightingControl_ToggleLinc: {LIGHT: [1], ON_OFF_EVENTS: [1]}, - EnergyManagement_LoadController: {SWITCH: [1], BINARY_SENSOR: [2]}, - GeneralController_ControlLinc: {ON_OFF_EVENTS: [1]}, - GeneralController_MiniRemote_4: {ON_OFF_EVENTS: range(1, 5)}, - GeneralController_MiniRemote_8: {ON_OFF_EVENTS: range(1, 9)}, - GeneralController_MiniRemote_Switch: {ON_OFF_EVENTS: [1, 2]}, - GeneralController_RemoteLinc: {ON_OFF_EVENTS: [1]}, - SecurityHealthSafety_DoorSensor: {BINARY_SENSOR: [1, 3, 4], ON_OFF_EVENTS: [1]}, - SecurityHealthSafety_LeakSensor: {BINARY_SENSOR: [2, 4]}, - SecurityHealthSafety_MotionSensor: {BINARY_SENSOR: [1, 2, 3], ON_OFF_EVENTS: [1]}, - SecurityHealthSafety_OpenCloseSensor: {BINARY_SENSOR: [1]}, - SecurityHealthSafety_Smokebridge: {BINARY_SENSOR: [1, 2, 3, 4, 6, 7]}, - SensorsActuators_IOLink: {SWITCH: [1], BINARY_SENSOR: [2], ON_OFF_EVENTS: [1, 2]}, - SwitchedLightingControl: {SWITCH: [1], ON_OFF_EVENTS: [1]}, - SwitchedLightingControl_ApplianceLinc: {SWITCH: [1], ON_OFF_EVENTS: [1]}, - SwitchedLightingControl_DinRail: {SWITCH: [1], ON_OFF_EVENTS: [1]}, - SwitchedLightingControl_I3Outlet: {SWITCH: [1, 2], ON_OFF_EVENTS: [1, 2]}, - SwitchedLightingControl_InLineLinc01: {SWITCH: [1], ON_OFF_EVENTS: [1]}, - SwitchedLightingControl_InLineLinc02: {SWITCH: [1], ON_OFF_EVENTS: [1]}, + DimmableLightingControl_LampLinc: {Platform.LIGHT: [1]}, + DimmableLightingControl_OutletLinc: {Platform.LIGHT: [1]}, + DimmableLightingControl_SwitchLinc01: {Platform.LIGHT: [1]}, + DimmableLightingControl_SwitchLinc02: {Platform.LIGHT: [1]}, + DimmableLightingControl_ToggleLinc: {Platform.LIGHT: [1]}, + EnergyManagement_LoadController: { + Platform.SWITCH: [1], + Platform.BINARY_SENSOR: [2], + }, + SecurityHealthSafety_DoorSensor: {Platform.BINARY_SENSOR: [1, 3, 4]}, + SecurityHealthSafety_LeakSensor: {Platform.BINARY_SENSOR: [2, 4]}, + SecurityHealthSafety_MotionSensor: {Platform.BINARY_SENSOR: [1, 2, 3]}, + SecurityHealthSafety_OpenCloseSensor: {Platform.BINARY_SENSOR: [1]}, + SecurityHealthSafety_Smokebridge: {Platform.BINARY_SENSOR: [1, 2, 3, 4, 6, 7]}, + SensorsActuators_IOLink: {Platform.SWITCH: [1], Platform.BINARY_SENSOR: [2]}, + SwitchedLightingControl: {Platform.SWITCH: [1]}, + SwitchedLightingControl_ApplianceLinc: {Platform.SWITCH: [1]}, + SwitchedLightingControl_DinRail: {Platform.SWITCH: [1]}, + SwitchedLightingControl_I3Outlet: {Platform.SWITCH: [1, 2]}, + SwitchedLightingControl_InLineLinc01: {Platform.SWITCH: [1]}, + SwitchedLightingControl_InLineLinc02: {Platform.SWITCH: [1]}, SwitchedLightingControl_KeypadLinc_6: { - SWITCH: [1, 3, 4, 5, 6], - ON_OFF_EVENTS: [1, 3, 4, 5, 6], + Platform.SWITCH: [1, 3, 4, 5, 6], }, SwitchedLightingControl_KeypadLinc_8: { - SWITCH: range(1, 9), - ON_OFF_EVENTS: range(1, 9), + Platform.SWITCH: range(1, 9), }, - SwitchedLightingControl_OnOffOutlet: {SWITCH: [1, 2], ON_OFF_EVENTS: [1, 2]}, - SwitchedLightingControl_OutletLinc: {SWITCH: [1], ON_OFF_EVENTS: [1]}, - SwitchedLightingControl_SwitchLinc01: {SWITCH: [1], ON_OFF_EVENTS: [1]}, - SwitchedLightingControl_SwitchLinc02: {SWITCH: [1], ON_OFF_EVENTS: [1]}, - SwitchedLightingControl_ToggleLinc: {SWITCH: [1], ON_OFF_EVENTS: [1]}, - ClimateControl_Thermostat: {CLIMATE: [1]}, - ClimateControl_WirelessThermostat: {CLIMATE: [1]}, - WindowCovering: {COVER: [1]}, - X10Dimmable: {LIGHT: [1]}, - X10OnOff: {SWITCH: [1]}, - X10OnOffSensor: {BINARY_SENSOR: [1]}, + SwitchedLightingControl_OnOffOutlet: {Platform.SWITCH: [1, 2]}, + SwitchedLightingControl_OutletLinc: {Platform.SWITCH: [1]}, + SwitchedLightingControl_SwitchLinc01: {Platform.SWITCH: [1]}, + SwitchedLightingControl_SwitchLinc02: {Platform.SWITCH: [1]}, + SwitchedLightingControl_ToggleLinc: {Platform.SWITCH: [1]}, + ClimateControl_Thermostat: {Platform.CLIMATE: [1]}, + ClimateControl_WirelessThermostat: {Platform.CLIMATE: [1]}, + WindowCovering: {Platform.COVER: [1]}, + X10Dimmable: {Platform.LIGHT: [1]}, + X10OnOff: {Platform.SWITCH: [1]}, + X10OnOffSensor: {Platform.BINARY_SENSOR: [1]}, } diff --git a/homeassistant/components/insteon/light.py b/homeassistant/components/insteon/light.py index 579945e6f821..44574c696b4e 100644 --- a/homeassistant/components/insteon/light.py +++ b/homeassistant/components/insteon/light.py @@ -3,13 +3,9 @@ from typing import Any from pyinsteon.config import ON_LEVEL -from homeassistant.components.light import ( - ATTR_BRIGHTNESS, - DOMAIN as LIGHT_DOMAIN, - ColorMode, - LightEntity, -) +from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -32,10 +28,14 @@ async def async_setup_entry( def async_add_insteon_light_entities(discovery_info=None): """Add the Insteon entities for the platform.""" async_add_insteon_entities( - hass, LIGHT_DOMAIN, InsteonDimmerEntity, async_add_entities, discovery_info + hass, + Platform.LIGHT, + InsteonDimmerEntity, + async_add_entities, + discovery_info, ) - signal = f"{SIGNAL_ADD_ENTITIES}_{LIGHT_DOMAIN}" + signal = f"{SIGNAL_ADD_ENTITIES}_{Platform.LIGHT}" async_dispatcher_connect(hass, signal, async_add_insteon_light_entities) async_add_insteon_light_entities() diff --git a/homeassistant/components/insteon/lock.py b/homeassistant/components/insteon/lock.py index 17a7cf20111f..75487e7696cd 100644 --- a/homeassistant/components/insteon/lock.py +++ b/homeassistant/components/insteon/lock.py @@ -2,8 +2,9 @@ from typing import Any -from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN, LockEntity +from homeassistant.components.lock import LockEntity from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -24,10 +25,10 @@ async def async_setup_entry( def async_add_insteon_lock_entities(discovery_info=None): """Add the Insteon entities for the platform.""" async_add_insteon_entities( - hass, LOCK_DOMAIN, InsteonLockEntity, async_add_entities, discovery_info + hass, Platform.LOCK, InsteonLockEntity, async_add_entities, discovery_info ) - signal = f"{SIGNAL_ADD_ENTITIES}_{LOCK_DOMAIN}" + signal = f"{SIGNAL_ADD_ENTITIES}_{Platform.LOCK}" async_dispatcher_connect(hass, signal, async_add_insteon_lock_entities) async_add_insteon_lock_entities() diff --git a/homeassistant/components/insteon/switch.py b/homeassistant/components/insteon/switch.py index d9a15d383c06..8f7c396f2133 100644 --- a/homeassistant/components/insteon/switch.py +++ b/homeassistant/components/insteon/switch.py @@ -1,8 +1,9 @@ """Support for INSTEON dimmers via PowerLinc Modem.""" from typing import Any -from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SwitchEntity +from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -23,10 +24,14 @@ async def async_setup_entry( def async_add_insteon_switch_entities(discovery_info=None): """Add the Insteon entities for the platform.""" async_add_insteon_entities( - hass, SWITCH_DOMAIN, InsteonSwitchEntity, async_add_entities, discovery_info + hass, + Platform.SWITCH, + InsteonSwitchEntity, + async_add_entities, + discovery_info, ) - signal = f"{SIGNAL_ADD_ENTITIES}_{SWITCH_DOMAIN}" + signal = f"{SIGNAL_ADD_ENTITIES}_{Platform.SWITCH}" async_dispatcher_connect(hass, signal, async_add_insteon_switch_entities) async_add_insteon_switch_entities() diff --git a/homeassistant/components/insteon/utils.py b/homeassistant/components/insteon/utils.py index 0df823e49b1c..8915342d2e1c 100644 --- a/homeassistant/components/insteon/utils.py +++ b/homeassistant/components/insteon/utils.py @@ -49,7 +49,6 @@ from .const import ( EVENT_GROUP_OFF_FAST, EVENT_GROUP_ON, EVENT_GROUP_ON_FAST, - ON_OFF_EVENTS, SIGNAL_ADD_DEFAULT_LINKS, SIGNAL_ADD_DEVICE_OVERRIDE, SIGNAL_ADD_ENTITIES, @@ -102,8 +101,8 @@ def _register_event(event: Event, listener: Callable) -> None: event.subscribe(listener, force_strong_ref=True) -def add_on_off_event_device(hass: HomeAssistant, device: Device) -> None: - """Register an Insteon device as an on/off event device.""" +def add_insteon_events(hass: HomeAssistant, device: Device) -> None: + """Register Insteon device events.""" @callback def async_fire_group_on_off_event( @@ -157,12 +156,8 @@ def register_new_device_callback(hass): await device.async_status() platforms = get_device_platforms(device) for platform in platforms: - if platform == ON_OFF_EVENTS: - add_on_off_event_device(hass, device) - - else: - signal = f"{SIGNAL_ADD_ENTITIES}_{platform}" - dispatcher_send(hass, signal, {"address": device.address}) + signal = f"{SIGNAL_ADD_ENTITIES}_{platform}" + dispatcher_send(hass, signal, {"address": device.address}) devices.subscribe(async_new_insteon_device, force_strong_ref=True) From b195d5d1db2df456effcbc25d8a684f422e6b767 Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Fri, 5 May 2023 23:01:57 +0200 Subject: [PATCH 230/822] Assemble platforms upfront in devolo Home Network (#80126) * Assemble platforms upfront in devolo Home Network * Add tests * Optimize mocks * Use async_forward_entry_setups * Adapt tests to newly added switch platform --- .../devolo_home_network/__init__.py | 30 +++++++++++++++---- .../devolo_home_network/binary_sensor.py | 15 +++++----- .../components/devolo_home_network/const.py | 8 ----- .../devolo_home_network/device_tracker.py | 9 +++--- .../devolo_home_network/conftest.py | 14 +++++++++ .../devolo_home_network/test_init.py | 30 +++++++++++++++++++ 6 files changed, 79 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/devolo_home_network/__init__.py b/homeassistant/components/devolo_home_network/__init__.py index 5fdb75bb5f94..d2c7b62a3998 100644 --- a/homeassistant/components/devolo_home_network/__init__.py +++ b/homeassistant/components/devolo_home_network/__init__.py @@ -20,8 +20,13 @@ from devolo_plc_api.plcnet_api import LogicalNetwork from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import Event, HomeAssistant +from homeassistant.const import ( + CONF_IP_ADDRESS, + CONF_PASSWORD, + EVENT_HOMEASSISTANT_STOP, + Platform, +) +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -32,7 +37,6 @@ from .const import ( DOMAIN, LONG_UPDATE_INTERVAL, NEIGHBORING_WIFI_NETWORKS, - PLATFORMS, SHORT_UPDATE_INTERVAL, SWITCH_GUEST_WIFI, SWITCH_LEDS, @@ -156,7 +160,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: for coordinator in coordinators.values(): await coordinator.async_config_entry_first_refresh() - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + await hass.config_entries.async_forward_entry_setups(entry, platforms(device)) entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, disconnect) @@ -167,9 +171,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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) + device: Device = hass.data[DOMAIN][entry.entry_id]["device"] + unload_ok = await hass.config_entries.async_unload_platforms( + entry, platforms(device) + ) if unload_ok: - await hass.data[DOMAIN][entry.entry_id]["device"].async_disconnect() + await device.async_disconnect() hass.data[DOMAIN].pop(entry.entry_id) return unload_ok + + +@callback +def platforms(device: Device) -> set[Platform]: + """Assemble supported platforms.""" + supported_platforms = {Platform.SENSOR, Platform.SWITCH} + if device.plcnet: + supported_platforms.add(Platform.BINARY_SENSOR) + if device.device and "wifi1" in device.device.features: + supported_platforms.add(Platform.DEVICE_TRACKER) + return supported_platforms diff --git a/homeassistant/components/devolo_home_network/binary_sensor.py b/homeassistant/components/devolo_home_network/binary_sensor.py index 809dc9086be5..b8f2551a891e 100644 --- a/homeassistant/components/devolo_home_network/binary_sensor.py +++ b/homeassistant/components/devolo_home_network/binary_sensor.py @@ -68,15 +68,14 @@ async def async_setup_entry( ]["coordinators"] entities: list[BinarySensorEntity] = [] - if device.plcnet: - entities.append( - DevoloBinarySensorEntity( - entry, - coordinators[CONNECTED_PLC_DEVICES], - SENSOR_TYPES[CONNECTED_TO_ROUTER], - device, - ) + entities.append( + DevoloBinarySensorEntity( + entry, + coordinators[CONNECTED_PLC_DEVICES], + SENSOR_TYPES[CONNECTED_TO_ROUTER], + device, ) + ) async_add_entities(entities) diff --git a/homeassistant/components/devolo_home_network/const.py b/homeassistant/components/devolo_home_network/const.py index fffe9b5d482e..193a0dc9a152 100644 --- a/homeassistant/components/devolo_home_network/const.py +++ b/homeassistant/components/devolo_home_network/const.py @@ -9,15 +9,7 @@ from devolo_plc_api.device_api import ( WIFI_VAP_MAIN_AP, ) -from homeassistant.const import Platform - DOMAIN = "devolo_home_network" -PLATFORMS = [ - Platform.BINARY_SENSOR, - Platform.DEVICE_TRACKER, - Platform.SENSOR, - Platform.SWITCH, -] PRODUCT = "product" SERIAL_NUMBER = "serial_number" diff --git a/homeassistant/components/devolo_home_network/device_tracker.py b/homeassistant/components/devolo_home_network/device_tracker.py index eb6e9cf6ec6e..c73e08abed20 100644 --- a/homeassistant/components/devolo_home_network/device_tracker.py +++ b/homeassistant/components/devolo_home_network/device_tracker.py @@ -73,11 +73,10 @@ async def async_setup_entry( async_add_entities(missing) - if device.device and "wifi1" in device.device.features: - restore_entities() - entry.async_on_unload( - coordinators[CONNECTED_WIFI_CLIENTS].async_add_listener(new_device_callback) - ) + restore_entities() + entry.async_on_unload( + coordinators[CONNECTED_WIFI_CLIENTS].async_add_listener(new_device_callback) + ) class DevoloScannerEntity( diff --git a/tests/components/devolo_home_network/conftest.py b/tests/components/devolo_home_network/conftest.py index 193b0d700eed..1eb91f7a48f6 100644 --- a/tests/components/devolo_home_network/conftest.py +++ b/tests/components/devolo_home_network/conftest.py @@ -19,6 +19,20 @@ def mock_device(): yield device +@pytest.fixture() +def mock_repeater_device(mock_device: MockDevice): + """Mock connecting to a devolo home network repeater device.""" + mock_device.plcnet = None + yield mock_device + + +@pytest.fixture() +def mock_nonwifi_device(mock_device: MockDevice): + """Mock connecting to a devolo home network device without wifi.""" + mock_device.device.features = ["reset", "update", "led", "intmtg"] + yield mock_device + + @pytest.fixture(name="info") def mock_validate_input(): """Mock setup entry and user input.""" diff --git a/tests/components/devolo_home_network/test_init.py b/tests/components/devolo_home_network/test_init.py index 1b24c21f2bc1..536053e149a5 100644 --- a/tests/components/devolo_home_network/test_init.py +++ b/tests/components/devolo_home_network/test_init.py @@ -4,10 +4,15 @@ from unittest.mock import patch from devolo_plc_api.exceptions.device import DeviceNotFound import pytest +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR +from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER from homeassistant.components.devolo_home_network.const import DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR +from homeassistant.components.switch import DOMAIN as SWITCH from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_IP_ADDRESS, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import async_get_platforms from . import configure_integration from .const import IP @@ -73,3 +78,28 @@ async def test_hass_stop(hass: HomeAssistant, mock_device: MockDevice) -> None: hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) await hass.async_block_till_done() mock_device.async_disconnect.assert_called_once() + + +@pytest.mark.parametrize( + "device, expected_platforms", + [ + ["mock_device", (BINARY_SENSOR, DEVICE_TRACKER, SENSOR, SWITCH)], + ["mock_repeater_device", (DEVICE_TRACKER, SENSOR, SWITCH)], + ["mock_nonwifi_device", (BINARY_SENSOR, SENSOR, SWITCH)], + ], +) +async def test_platforms( + hass: HomeAssistant, + device: str, + expected_platforms: set[str], + request: pytest.FixtureRequest, +): + """Test platform assembly.""" + request.getfixturevalue(device) + entry = configure_integration(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + platforms = [platform.domain for platform in async_get_platforms(hass, DOMAIN)] + assert len(platforms) == len(expected_platforms) + assert all(platform in platforms for platform in expected_platforms) From 1af1f4db0c4aed72cb1be806b9f076e64eb69d6d Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Sat, 6 May 2023 00:11:05 +0200 Subject: [PATCH 231/822] Fix devolo home lint errors from ruff (#92649) --- tests/components/devolo_home_network/conftest.py | 8 ++++---- tests/components/devolo_home_network/test_init.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/components/devolo_home_network/conftest.py b/tests/components/devolo_home_network/conftest.py index 1eb91f7a48f6..9f30ca74cb08 100644 --- a/tests/components/devolo_home_network/conftest.py +++ b/tests/components/devolo_home_network/conftest.py @@ -19,18 +19,18 @@ def mock_device(): yield device -@pytest.fixture() +@pytest.fixture def mock_repeater_device(mock_device: MockDevice): """Mock connecting to a devolo home network repeater device.""" mock_device.plcnet = None - yield mock_device + return mock_device -@pytest.fixture() +@pytest.fixture def mock_nonwifi_device(mock_device: MockDevice): """Mock connecting to a devolo home network device without wifi.""" mock_device.device.features = ["reset", "update", "led", "intmtg"] - yield mock_device + return mock_device @pytest.fixture(name="info") diff --git a/tests/components/devolo_home_network/test_init.py b/tests/components/devolo_home_network/test_init.py index 536053e149a5..038028e66d30 100644 --- a/tests/components/devolo_home_network/test_init.py +++ b/tests/components/devolo_home_network/test_init.py @@ -81,7 +81,7 @@ async def test_hass_stop(hass: HomeAssistant, mock_device: MockDevice) -> None: @pytest.mark.parametrize( - "device, expected_platforms", + ("device", "expected_platforms"), [ ["mock_device", (BINARY_SENSOR, DEVICE_TRACKER, SENSOR, SWITCH)], ["mock_repeater_device", (DEVICE_TRACKER, SENSOR, SWITCH)], From 9a65a89aa468d15fc3fcef45fd6587ddbc16ec07 Mon Sep 17 00:00:00 2001 From: rikroe <42204099+rikroe@users.noreply.github.com> Date: Sat, 6 May 2023 10:00:37 +0200 Subject: [PATCH 232/822] Improve internet/API error handling for BMW (#90274) * Improve internet/API error handling * Switch to library exceptions for HTTP status errors * Remove duplicate logging after reconnect * Raise UpdateFailed instead of custom log handling --------- Co-authored-by: rikroe Co-authored-by: G Johansson --- .../bmw_connected_drive/config_flow.py | 13 ++++- .../bmw_connected_drive/coordinator.py | 27 ++++------- .../bmw_connected_drive/test_config_flow.py | 48 ++++++++++++++++--- 3 files changed, 60 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/bmw_connected_drive/config_flow.py b/homeassistant/components/bmw_connected_drive/config_flow.py index 0cde37ba6b34..eb58a6c7f13f 100644 --- a/homeassistant/components/bmw_connected_drive/config_flow.py +++ b/homeassistant/components/bmw_connected_drive/config_flow.py @@ -6,7 +6,8 @@ from typing import Any from bimmer_connected.api.authentication import MyBMWAuthentication from bimmer_connected.api.regions import get_region_from_name -from httpx import HTTPError +from bimmer_connected.models import MyBMWAPIError, MyBMWAuthError +from httpx import RequestError import voluptuous as vol from homeassistant import config_entries, core, exceptions @@ -41,7 +42,9 @@ async def validate_input( try: await auth.login() - except HTTPError as ex: + except MyBMWAuthError as ex: + raise InvalidAuth from ex + except (MyBMWAPIError, RequestError) as ex: raise CannotConnect from ex # Return info that you want to store in the config entry. @@ -80,6 +83,8 @@ class BMWConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): } except CannotConnect: errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" if info: if self._reauth_entry: @@ -160,3 +165,7 @@ class BMWOptionsFlow(config_entries.OptionsFlowWithConfigEntry): class CannotConnect(exceptions.HomeAssistantError): """Error to indicate we cannot connect.""" + + +class InvalidAuth(exceptions.HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/bmw_connected_drive/coordinator.py b/homeassistant/components/bmw_connected_drive/coordinator.py index ae139d4c64a6..9c3e15a808b7 100644 --- a/homeassistant/components/bmw_connected_drive/coordinator.py +++ b/homeassistant/components/bmw_connected_drive/coordinator.py @@ -6,8 +6,8 @@ import logging from bimmer_connected.account import MyBMWAccount from bimmer_connected.api.regions import get_region_from_name -from bimmer_connected.models import GPSPosition -from httpx import HTTPError, HTTPStatusError, TimeoutException +from bimmer_connected.models import GPSPosition, MyBMWAPIError, MyBMWAuthError +from httpx import RequestError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME @@ -56,20 +56,12 @@ class BMWDataUpdateCoordinator(DataUpdateCoordinator[None]): try: await self.account.get_vehicles() - except (HTTPError, HTTPStatusError, TimeoutException) as err: - if isinstance(err, HTTPStatusError) and err.response.status_code == 429: - # Increase scan interval to not jump to not bring up the issue next time - self.update_interval = timedelta( - seconds=DEFAULT_SCAN_INTERVAL_SECONDS * 3 - ) - if isinstance(err, HTTPStatusError) and err.response.status_code in ( - 401, - 403, - ): - # Clear refresh token only and trigger reauth - self._update_config_entry_refresh_token(None) - raise ConfigEntryAuthFailed(str(err)) from err - raise UpdateFailed(f"Error communicating with BMW API: {err}") from err + except MyBMWAuthError as err: + # Clear refresh token and trigger reauth + self._update_config_entry_refresh_token(None) + raise ConfigEntryAuthFailed(err) from err + except (MyBMWAPIError, RequestError) as err: + raise UpdateFailed(err) from err if self.account.refresh_token != old_refresh_token: self._update_config_entry_refresh_token(self.account.refresh_token) @@ -79,9 +71,6 @@ class BMWDataUpdateCoordinator(DataUpdateCoordinator[None]): self.account.refresh_token, ) - # Reset scan interval after successful update - self.update_interval = timedelta(seconds=DEFAULT_SCAN_INTERVAL_SECONDS) - def _update_config_entry_refresh_token(self, refresh_token: str | None) -> None: """Update or delete the refresh_token in the Config Entry.""" data = { diff --git a/tests/components/bmw_connected_drive/test_config_flow.py b/tests/components/bmw_connected_drive/test_config_flow.py index 4db57ad30228..e471a61d027c 100644 --- a/tests/components/bmw_connected_drive/test_config_flow.py +++ b/tests/components/bmw_connected_drive/test_config_flow.py @@ -3,7 +3,8 @@ from copy import deepcopy from unittest.mock import patch from bimmer_connected.api.authentication import MyBMWAuthentication -from httpx import HTTPError +from bimmer_connected.models import MyBMWAPIError, MyBMWAuthError +from httpx import RequestError from homeassistant import config_entries, data_entry_flow from homeassistant.components.bmw_connected_drive.config_flow import DOMAIN @@ -37,15 +38,48 @@ async def test_show_form(hass: HomeAssistant) -> None: assert result["step_id"] == "user" -async def test_connection_error(hass: HomeAssistant) -> None: - """Test we show user form on BMW connected drive connection error.""" - - def _mock_get_oauth_token(*args, **kwargs): - pass +async def test_authentication_error(hass: HomeAssistant) -> None: + """Test we show user form on MyBMW authentication error.""" with patch( "bimmer_connected.api.authentication.MyBMWAuthentication.login", - side_effect=HTTPError("login failure"), + side_effect=MyBMWAuthError("Login failed"), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=FIXTURE_USER_INPUT, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_auth"} + + +async def test_connection_error(hass: HomeAssistant) -> None: + """Test we show user form on MyBMW API error.""" + + with patch( + "bimmer_connected.api.authentication.MyBMWAuthentication.login", + side_effect=RequestError("Connection reset"), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=FIXTURE_USER_INPUT, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_api_error(hass: HomeAssistant) -> None: + """Test we show user form on general connection error.""" + + with patch( + "bimmer_connected.api.authentication.MyBMWAuthentication.login", + side_effect=MyBMWAPIError("400 Bad Request"), ): result = await hass.config_entries.flow.async_init( DOMAIN, From 7e041a95c9919ba9dac9f3edc56a8fffa571f448 Mon Sep 17 00:00:00 2001 From: rikroe <42204099+rikroe@users.noreply.github.com> Date: Sat, 6 May 2023 10:05:57 +0200 Subject: [PATCH 233/822] Bump bimmer_connected to 0.13.3 (#92648) Co-authored-by: rikroe --- homeassistant/components/bmw_connected_drive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index 3c7d2ba27c3a..afabcbd3df4a 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", "iot_class": "cloud_polling", "loggers": ["bimmer_connected"], - "requirements": ["bimmer_connected==0.13.2"] + "requirements": ["bimmer_connected==0.13.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 95504b93ea57..e39f186fcb1d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -431,7 +431,7 @@ beautifulsoup4==4.11.1 bellows==0.35.2 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.13.2 +bimmer_connected==0.13.3 # homeassistant.components.bizkaibus bizkaibus==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9364c3e66fd7..c08de372edd8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -364,7 +364,7 @@ beautifulsoup4==4.11.1 bellows==0.35.2 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.13.2 +bimmer_connected==0.13.3 # homeassistant.components.bluetooth bleak-retry-connector==3.0.2 From e680ec6247a74e3d645c20fe3d13265c72b3c9f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20L=C3=B6vdahl?= Date: Sat, 6 May 2023 11:23:14 +0300 Subject: [PATCH 234/822] Remove deprecated Vallox YAML configuration (#91096) Co-authored-by: G Johansson --- homeassistant/components/vallox/__init__.py | 20 +-- .../components/vallox/config_flow.py | 32 ---- tests/components/vallox/test_config_flow.py | 144 +----------------- 3 files changed, 3 insertions(+), 193 deletions(-) diff --git a/homeassistant/components/vallox/__init__.py b/homeassistant/components/vallox/__init__.py index 579accc80903..6f8d00eb48c2 100644 --- a/homeassistant/components/vallox/__init__.py +++ b/homeassistant/components/vallox/__init__.py @@ -17,12 +17,12 @@ from vallox_websocket_api.vallox import ( ) import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_NAME, Platform from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.typing import ConfigType, StateType +from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -159,22 +159,6 @@ class ValloxDataUpdateCoordinator(DataUpdateCoordinator[ValloxState]): """The DataUpdateCoordinator for Vallox.""" -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the integration from configuration.yaml (DEPRECATED).""" - if DOMAIN not in config: - return True - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config[DOMAIN], - ) - ) - - return True - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the client and boot the platforms.""" host = entry.data[CONF_HOST] diff --git a/homeassistant/components/vallox/config_flow.py b/homeassistant/components/vallox/config_flow.py index b9d29b17689b..cfc5993797d1 100644 --- a/homeassistant/components/vallox/config_flow.py +++ b/homeassistant/components/vallox/config_flow.py @@ -40,38 +40,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - async def async_step_import(self, data: dict[str, Any]) -> FlowResult: - """Handle import from YAML.""" - # We need to use the name from the YAML configuration to avoid - # breaking existing entity IDs. - name = data.get(CONF_NAME, DEFAULT_NAME) - host = data[CONF_HOST] - - self._async_abort_entries_match({CONF_HOST: host}) - - reason = None - try: - await validate_host(self.hass, host) - except InvalidHost: - _LOGGER.error("An invalid host is configured for Vallox: %s", host) - reason = "invalid_host" - except ValloxApiException: - _LOGGER.error("Cannot connect to Vallox host %s", host) - reason = "cannot_connect" - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception") - reason = "unknown" - else: - return self.async_create_entry( - title=name, - data={ - **data, - CONF_NAME: name, - }, - ) - - return self.async_abort(reason=reason) - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: diff --git a/tests/components/vallox/test_config_flow.py b/tests/components/vallox/test_config_flow.py index 39de026bdbbd..2bf29837d9df 100644 --- a/tests/components/vallox/test_config_flow.py +++ b/tests/components/vallox/test_config_flow.py @@ -4,7 +4,7 @@ from unittest.mock import patch from vallox_websocket_api import ValloxApiException, ValloxWebsocketException from homeassistant.components.vallox.const import DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -150,145 +150,3 @@ async def test_form_already_configured(hass: HomeAssistant) -> None: assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" - - -async def test_import_with_custom_name(hass: HomeAssistant) -> None: - """Test that import is handled.""" - name = "Vallox 90 MV" - - with patch( - "homeassistant.components.vallox.config_flow.Vallox.get_info", - return_value=None, - ), patch( - "homeassistant.components.vallox.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={"host": "1.2.3.4", "name": name}, - ) - await hass.async_block_till_done() - - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == name - assert result["data"] == {"host": "1.2.3.4", "name": "Vallox 90 MV"} - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_import_without_custom_name(hass: HomeAssistant) -> None: - """Test that import is handled.""" - with patch( - "homeassistant.components.vallox.config_flow.Vallox.get_info", - return_value=None, - ), patch( - "homeassistant.components.vallox.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={"host": "1.2.3.4"}, - ) - await hass.async_block_till_done() - - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "Vallox" - assert result["data"] == {"host": "1.2.3.4", "name": "Vallox"} - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_import_invalid_ip(hass: HomeAssistant) -> None: - """Test that invalid IP error is handled during import.""" - name = "Vallox 90 MV" - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={"host": "vallox90mv.host.name", "name": name}, - ) - await hass.async_block_till_done() - - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "invalid_host" - - -async def test_import_already_configured(hass: HomeAssistant) -> None: - """Test that an already configured Vallox device is handled during import.""" - name = "Vallox 145 MV" - - mock_entry = MockConfigEntry( - domain=DOMAIN, - data={ - CONF_HOST: "40.10.20.30", - CONF_NAME: "Vallox 145 MV", - }, - ) - mock_entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={"host": "40.10.20.30", "name": name}, - ) - await hass.async_block_till_done() - - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "already_configured" - - -async def test_import_cannot_connect_os_error(hass: HomeAssistant) -> None: - """Test that cannot connect error is handled.""" - name = "Vallox 90 MV" - - with patch( - "homeassistant.components.vallox.config_flow.Vallox.get_info", - side_effect=ValloxWebsocketException, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={"host": "1.2.3.4", "name": name}, - ) - await hass.async_block_till_done() - - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "cannot_connect" - - -async def test_import_cannot_connect_vallox_api_exception(hass: HomeAssistant) -> None: - """Test that cannot connect error is handled.""" - name = "Vallox 90 MV" - - with patch( - "homeassistant.components.vallox.config_flow.Vallox.get_info", - side_effect=ValloxApiException, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={"host": "5.6.3.1", "name": name}, - ) - await hass.async_block_till_done() - - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "cannot_connect" - - -async def test_import_unknown_exception(hass: HomeAssistant) -> None: - """Test that unknown exceptions are handled.""" - name = "Vallox 245 MV" - - with patch( - "homeassistant.components.vallox.config_flow.Vallox.get_info", - side_effect=Exception, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={"host": "1.2.3.4", "name": name}, - ) - await hass.async_block_till_done() - - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "unknown" From f9fe3f4af472387590acf01a12fda80ea97ec03f Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Sat, 6 May 2023 11:41:11 +0200 Subject: [PATCH 235/822] Add nibe heat pump water heater entities (#79099) * Add water heater platform * Enable water heater platform * No need to set target temp feature * Split out preset mode * Switch to parameters from lib * Drop presets * Add missing callback to coordinator update * Drop support for heatpump activity - Current entity model does not support it * Handle s series lack of mappings * Protect for missing operation modes to set --- .coveragerc | 1 + .../components/nibe_heatpump/__init__.py | 1 + .../components/nibe_heatpump/const.py | 3 + .../components/nibe_heatpump/water_heater.py | 185 ++++++++++++++++++ 4 files changed, 190 insertions(+) create mode 100644 homeassistant/components/nibe_heatpump/water_heater.py diff --git a/.coveragerc b/.coveragerc index 4827d93ed526..1bb8bdcb6875 100644 --- a/.coveragerc +++ b/.coveragerc @@ -788,6 +788,7 @@ omit = homeassistant/components/nibe_heatpump/select.py homeassistant/components/nibe_heatpump/sensor.py homeassistant/components/nibe_heatpump/switch.py + homeassistant/components/nibe_heatpump/water_heater.py homeassistant/components/niko_home_control/light.py homeassistant/components/nilu/air_quality.py homeassistant/components/nissan_leaf/* diff --git a/homeassistant/components/nibe_heatpump/__init__.py b/homeassistant/components/nibe_heatpump/__init__.py index 89aac6bed61d..b46102879c4a 100644 --- a/homeassistant/components/nibe_heatpump/__init__.py +++ b/homeassistant/components/nibe_heatpump/__init__.py @@ -54,6 +54,7 @@ PLATFORMS: list[Platform] = [ Platform.SELECT, Platform.SENSOR, Platform.SWITCH, + Platform.WATER_HEATER, ] COIL_READ_RETRIES = 5 diff --git a/homeassistant/components/nibe_heatpump/const.py b/homeassistant/components/nibe_heatpump/const.py index dc6b4b18996b..0f16567671c6 100644 --- a/homeassistant/components/nibe_heatpump/const.py +++ b/homeassistant/components/nibe_heatpump/const.py @@ -17,4 +17,7 @@ CONF_MODBUS_UNIT = "modbus_unit" VALUES_MIXING_VALVE_CLOSED_STATE = (30, "CLOSED", "SHUNT CLOSED") VALUES_PRIORITY_HEATING = (30, "HEAT") VALUES_PRIORITY_COOLING = (60, "COOLING") +VALUES_PRIORITY_HOT_WATER = (20, "HOT WATER") +VALUES_TEMPORARY_LUX_INACTIVE = "OFF" +VALUES_TEMPORARY_LUX_ONE_TIME_INCREASE = "ONE TIME INCREASE" VALUES_COOL_WITH_ROOM_SENSOR_OFF = (0, "OFF") diff --git a/homeassistant/components/nibe_heatpump/water_heater.py b/homeassistant/components/nibe_heatpump/water_heater.py new file mode 100644 index 000000000000..0c6063807764 --- /dev/null +++ b/homeassistant/components/nibe_heatpump/water_heater.py @@ -0,0 +1,185 @@ +"""The Nibe Heat Pump sensors.""" +from __future__ import annotations + +from nibe.coil import Coil +from nibe.coil_groups import WATER_HEATER_COILGROUPS, WaterHeaterCoilGroup +from nibe.exceptions import CoilNotFoundException + +from homeassistant.components.water_heater import ( + STATE_HEAT_PUMP, + STATE_HIGH_DEMAND, + WaterHeaterEntity, + WaterHeaterEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import DOMAIN, LOGGER, Coordinator +from .const import VALUES_TEMPORARY_LUX_INACTIVE, VALUES_TEMPORARY_LUX_ONE_TIME_INCREASE + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up platform.""" + + coordinator: Coordinator = hass.data[DOMAIN][config_entry.entry_id] + + def water_heaters(): + for key, group in WATER_HEATER_COILGROUPS.get(coordinator.series, ()).items(): + try: + yield WaterHeater(coordinator, key, group) + except CoilNotFoundException as exception: + LOGGER.debug("Skipping water heater: %r", exception) + + async_add_entities(water_heaters()) + + +class WaterHeater(CoordinatorEntity[Coordinator], WaterHeaterEntity): + """Sensor entity.""" + + _attr_entity_category = None + _attr_has_entity_name = True + _attr_supported_features = WaterHeaterEntityFeature.OPERATION_MODE + _attr_max_temp = 35.0 + _attr_min_temp = 5.0 + + def __init__( + self, + coordinator: Coordinator, + key: str, + desc: WaterHeaterCoilGroup, + ) -> None: + """Initialize entity.""" + + super().__init__( + coordinator, + { + desc.hot_water_load, + desc.hot_water_comfort_mode, + *set(desc.start_temperature.values()), + *set(desc.stop_temperature.values()), + desc.active_accessory, + desc.temporary_lux, + }, + ) + self._attr_entity_registry_enabled_default = desc.active_accessory is None + self._attr_available = False + self._attr_name = desc.name + self._attr_unique_id = f"{coordinator.unique_id}-{key}" + self._attr_device_info = coordinator.device_info + + self._attr_current_operation = None + self._attr_target_temperature_high = None + self._attr_target_temperature_low = None + self._attr_operation_list = [] + self._operation_mode_to_lux: dict[str, str] = {} + + def _get(address: int) -> Coil: + return coordinator.heatpump.get_coil_by_address(address) + + def _map(data: dict[str, int]) -> dict[str, Coil]: + return {key: _get(address) for key, address in data.items()} + + self._coil_current = _get(desc.hot_water_load) + self._coil_start_temperature = _map(desc.start_temperature) + self._coil_stop_temperature = _map(desc.stop_temperature) + self._coil_temporary_lux: Coil | None = None + if desc.temporary_lux: + self._coil_temporary_lux = _get(desc.temporary_lux) + self._coil_active_accessory: Coil | None = None + if address := desc.active_accessory: + self._coil_active_accessory = _get(address) + + self._coil_hot_water_comfort_mode = _get(desc.hot_water_comfort_mode) + + def _add_lux_mode(temporary_lux: str, operation_mode: str) -> None: + assert self._attr_operation_list is not None + if ( + not self._coil_temporary_lux + or not self._coil_temporary_lux.reverse_mappings + ): + return + + if temporary_lux not in self._coil_temporary_lux.reverse_mappings: + return + + self._attr_operation_list.append(operation_mode) + self._operation_mode_to_lux[operation_mode] = temporary_lux + + _add_lux_mode(VALUES_TEMPORARY_LUX_ONE_TIME_INCREASE, STATE_HIGH_DEMAND) + _add_lux_mode(VALUES_TEMPORARY_LUX_INACTIVE, STATE_HEAT_PUMP) + + self._attr_temperature_unit = self._coil_current.unit + + @callback + def _handle_coordinator_update(self) -> None: + if not self.coordinator.data: + return + + def _get_float(coil: Coil | None) -> float | None: + if coil is None: + return None + return self.coordinator.get_coil_float(coil) + + def _get_value(coil: Coil | None) -> int | str | float | None: + if coil is None: + return None + return self.coordinator.get_coil_value(coil) + + self._attr_current_temperature = _get_float(self._coil_current) + + if (mode := _get_value(self._coil_hot_water_comfort_mode)) and isinstance( + mode, str + ): + self._attr_target_temperature_low = _get_float( + self._coil_start_temperature.get(mode) + ) + self._attr_target_temperature_high = _get_float( + self._coil_stop_temperature.get(mode) + ) + else: + self._attr_target_temperature_low = None + self._attr_target_temperature_high = None + + if ( + _get_value(self._coil_temporary_lux) + == VALUES_TEMPORARY_LUX_ONE_TIME_INCREASE + ): + self._attr_current_operation = STATE_HIGH_DEMAND + else: + self._attr_current_operation = STATE_HEAT_PUMP + + super()._handle_coordinator_update() + + @property + def available(self) -> bool: + """Return if entity is available.""" + if not self.coordinator.last_update_success: + return False + + if not self._coil_active_accessory: + return True + + if active_accessory := self.coordinator.get_coil_value( + self._coil_active_accessory + ): + return active_accessory == "ON" + + return False + + async def async_set_operation_mode(self, operation_mode: str) -> None: + """Set new target operation mode.""" + if not self._coil_temporary_lux: + raise HomeAssistantError("Not supported") + + lux = self._operation_mode_to_lux.get(operation_mode) + if not lux: + raise ValueError(f"Unsupported operation mode {operation_mode}") + + await self.coordinator.async_write_coil(self._coil_temporary_lux, lux) From 053eaad2bd09ac1b656be5ae22b379f36891a295 Mon Sep 17 00:00:00 2001 From: Christoffer Poulsen Date: Sat, 6 May 2023 11:57:53 +0200 Subject: [PATCH 236/822] Add charging target sensor to bmw_connected_drive (#92628) * Add chaging status sensor * Add unit type based on review --- homeassistant/components/bmw_connected_drive/sensor.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/homeassistant/components/bmw_connected_drive/sensor.py b/homeassistant/components/bmw_connected_drive/sensor.py index c797de99859a..b15a95fde245 100644 --- a/homeassistant/components/bmw_connected_drive/sensor.py +++ b/homeassistant/components/bmw_connected_drive/sensor.py @@ -73,6 +73,13 @@ SENSOR_TYPES: dict[str, BMWSensorEntityDescription] = { icon="mdi:ev-station", value=lambda x, y: x.value, ), + "charging_target": BMWSensorEntityDescription( + key="charging_target", + name="Charging target", + key_class="fuel_and_battery", + icon="mdi:battery-charging-high", + unit_type=PERCENTAGE, + ), "remaining_battery_percent": BMWSensorEntityDescription( key="remaining_battery_percent", name="Remaining battery percent", From d816da9355be227796ddb7174c48d9d1675e69fb Mon Sep 17 00:00:00 2001 From: Artem Draft Date: Sat, 6 May 2023 17:18:34 +0300 Subject: [PATCH 237/822] Add media_player platform to Android TV Remote (#91677) --- CODEOWNERS | 4 +- .../components/androidtv_remote/__init__.py | 25 +- .../components/androidtv_remote/entity.py | 84 +++++ .../components/androidtv_remote/manifest.json | 4 +- .../androidtv_remote/media_player.py | 198 +++++++++++ .../components/androidtv_remote/remote.py | 100 ++---- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/androidtv_remote/conftest.py | 53 ++- .../androidtv_remote/test_media_player.py | 314 ++++++++++++++++++ .../androidtv_remote/test_remote.py | 47 +-- 11 files changed, 702 insertions(+), 131 deletions(-) create mode 100644 homeassistant/components/androidtv_remote/entity.py create mode 100644 homeassistant/components/androidtv_remote/media_player.py create mode 100644 tests/components/androidtv_remote/test_media_player.py diff --git a/CODEOWNERS b/CODEOWNERS index e3d53d903e6a..d17dd12d448b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -80,8 +80,8 @@ build.json @home-assistant/supervisor /tests/components/android_ip_webcam/ @engrbm87 /homeassistant/components/androidtv/ @JeffLIrion @ollo69 /tests/components/androidtv/ @JeffLIrion @ollo69 -/homeassistant/components/androidtv_remote/ @tronikos -/tests/components/androidtv_remote/ @tronikos +/homeassistant/components/androidtv_remote/ @tronikos @Drafteed +/tests/components/androidtv_remote/ @tronikos @Drafteed /homeassistant/components/anova/ @Lash-L /tests/components/anova/ @Lash-L /homeassistant/components/anthemav/ @hyralex diff --git a/homeassistant/components/androidtv_remote/__init__.py b/homeassistant/components/androidtv_remote/__init__.py index fb275342cb0c..bdcf08bb2f62 100644 --- a/homeassistant/components/androidtv_remote/__init__.py +++ b/homeassistant/components/androidtv_remote/__init__.py @@ -1,6 +1,8 @@ """The Android TV Remote integration.""" from __future__ import annotations +import logging + from androidtvremote2 import ( AndroidTVRemote, CannotConnect, @@ -9,20 +11,37 @@ from androidtvremote2 import ( ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP, Platform +from homeassistant.const import CONF_HOST, CONF_NAME, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from .const import DOMAIN from .helpers import create_api -PLATFORMS: list[Platform] = [Platform.REMOTE] +_LOGGER = logging.getLogger(__name__) + +PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER, Platform.REMOTE] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Android TV Remote from a config entry.""" - api = create_api(hass, entry.data[CONF_HOST]) + + @callback + def is_available_updated(is_available: bool) -> None: + if is_available: + _LOGGER.info( + "Reconnected to %s at %s", entry.data[CONF_NAME], entry.data[CONF_HOST] + ) + else: + _LOGGER.warning( + "Disconnected from %s at %s", + entry.data[CONF_NAME], + entry.data[CONF_HOST], + ) + + api.add_is_available_updated_callback(is_available_updated) + try: await api.async_connect() except InvalidAuth as exc: diff --git a/homeassistant/components/androidtv_remote/entity.py b/homeassistant/components/androidtv_remote/entity.py new file mode 100644 index 000000000000..862f317ee82d --- /dev/null +++ b/homeassistant/components/androidtv_remote/entity.py @@ -0,0 +1,84 @@ +"""Base entity for Android TV Remote.""" +from __future__ import annotations + +from androidtvremote2 import AndroidTVRemote, ConnectionClosed + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME +from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers.entity import DeviceInfo, Entity + +from .const import DOMAIN + + +class AndroidTVRemoteBaseEntity(Entity): + """Android TV Remote Base Entity.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__(self, api: AndroidTVRemote, config_entry: ConfigEntry) -> None: + """Initialize the entity.""" + self._api = api + self._host = config_entry.data[CONF_HOST] + self._name = config_entry.data[CONF_NAME] + self._attr_unique_id = config_entry.unique_id + self._attr_is_on = api.is_on + device_info = api.device_info + assert config_entry.unique_id + assert device_info + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, config_entry.data[CONF_MAC])}, + identifiers={(DOMAIN, config_entry.unique_id)}, + name=self._name, + manufacturer=device_info["manufacturer"], + model=device_info["model"], + ) + + @callback + def _is_available_updated(self, is_available: bool) -> None: + """Update the state when the device is ready to receive commands or is unavailable.""" + self._attr_available = is_available + self.async_write_ha_state() + + @callback + def _is_on_updated(self, is_on: bool) -> None: + """Update the state when device turns on or off.""" + self._attr_is_on = is_on + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + self._api.add_is_available_updated_callback(self._is_available_updated) + self._api.add_is_on_updated_callback(self._is_on_updated) + + async def async_will_remove_from_hass(self) -> None: + """Remove callbacks.""" + self._api.remove_is_available_updated_callback(self._is_available_updated) + self._api.remove_is_on_updated_callback(self._is_on_updated) + + def _send_key_command(self, key_code: str, direction: str = "SHORT") -> None: + """Send a key press to Android TV. + + This does not block; it buffers the data and arranges for it to be sent out asynchronously. + """ + try: + self._api.send_key_command(key_code, direction) + except ConnectionClosed as exc: + raise HomeAssistantError( + "Connection to Android TV device is closed" + ) from exc + + def _send_launch_app_command(self, app_link: str) -> None: + """Launch an app on Android TV. + + This does not block; it buffers the data and arranges for it to be sent out asynchronously. + """ + try: + self._api.send_launch_app_command(app_link) + except ConnectionClosed as exc: + raise HomeAssistantError( + "Connection to Android TV device is closed" + ) from exc diff --git a/homeassistant/components/androidtv_remote/manifest.json b/homeassistant/components/androidtv_remote/manifest.json index 0e5d896a11f9..9273e82a51b7 100644 --- a/homeassistant/components/androidtv_remote/manifest.json +++ b/homeassistant/components/androidtv_remote/manifest.json @@ -1,13 +1,13 @@ { "domain": "androidtv_remote", "name": "Android TV Remote", - "codeowners": ["@tronikos"], + "codeowners": ["@tronikos", "@Drafteed"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/androidtv_remote", "integration_type": "device", "iot_class": "local_push", "loggers": ["androidtvremote2"], "quality_scale": "platinum", - "requirements": ["androidtvremote2==0.0.7"], + "requirements": ["androidtvremote2==0.0.8"], "zeroconf": ["_androidtvremote2._tcp.local."] } diff --git a/homeassistant/components/androidtv_remote/media_player.py b/homeassistant/components/androidtv_remote/media_player.py new file mode 100644 index 000000000000..eccfc8ce25b6 --- /dev/null +++ b/homeassistant/components/androidtv_remote/media_player.py @@ -0,0 +1,198 @@ +"""Media player support for Android TV Remote.""" +from __future__ import annotations + +import asyncio +from typing import Any + +from androidtvremote2 import AndroidTVRemote, ConnectionClosed + +from homeassistant.components.media_player import ( + MediaPlayerDeviceClass, + MediaPlayerEntity, + MediaPlayerEntityFeature, + MediaPlayerState, + MediaType, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import AndroidTVRemoteBaseEntity + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Android TV media player entity based on a config entry.""" + api: AndroidTVRemote = hass.data[DOMAIN][config_entry.entry_id] + async_add_entities([AndroidTVRemoteMediaPlayerEntity(api, config_entry)]) + + +class AndroidTVRemoteMediaPlayerEntity(AndroidTVRemoteBaseEntity, MediaPlayerEntity): + """Android TV Remote Media Player Entity.""" + + _attr_assumed_state = True + _attr_device_class = MediaPlayerDeviceClass.TV + _attr_supported_features = ( + MediaPlayerEntityFeature.PAUSE + | MediaPlayerEntityFeature.VOLUME_STEP + | MediaPlayerEntityFeature.VOLUME_MUTE + | MediaPlayerEntityFeature.PREVIOUS_TRACK + | MediaPlayerEntityFeature.NEXT_TRACK + | MediaPlayerEntityFeature.TURN_ON + | MediaPlayerEntityFeature.TURN_OFF + | MediaPlayerEntityFeature.PLAY + | MediaPlayerEntityFeature.STOP + | MediaPlayerEntityFeature.PLAY_MEDIA + ) + + def __init__(self, api: AndroidTVRemote, config_entry: ConfigEntry) -> None: + """Initialize the entity.""" + super().__init__(api, config_entry) + + # This task is needed to create a job that sends a key press + # sequence that can be canceled if concurrency occurs + self._channel_set_task: asyncio.Task | None = None + + def _update_current_app(self, current_app: str) -> None: + """Update current app info.""" + self._attr_app_id = current_app + self._attr_app_name = current_app + + def _update_volume_info(self, volume_info: dict[str, str | bool]) -> None: + """Update volume info.""" + if volume_info.get("max"): + self._attr_volume_level = int(volume_info["level"]) / int( + volume_info["max"] + ) + self._attr_is_volume_muted = bool(volume_info["muted"]) + else: + self._attr_volume_level = None + self._attr_is_volume_muted = None + + @callback + def _current_app_updated(self, current_app: str) -> None: + """Update the state when the current app changes.""" + self._update_current_app(current_app) + self.async_write_ha_state() + + @callback + def _volume_info_updated(self, volume_info: dict[str, str | bool]) -> None: + """Update the state when the volume info changes.""" + self._update_volume_info(volume_info) + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + await super().async_added_to_hass() + + self._update_current_app(self._api.current_app) + self._update_volume_info(self._api.volume_info) + + self._api.add_current_app_updated_callback(self._current_app_updated) + self._api.add_volume_info_updated_callback(self._volume_info_updated) + + async def async_will_remove_from_hass(self) -> None: + """Remove callbacks.""" + await super().async_will_remove_from_hass() + + self._api.remove_current_app_updated_callback(self._current_app_updated) + self._api.remove_volume_info_updated_callback(self._volume_info_updated) + + @property + def state(self) -> MediaPlayerState: + """Return the state of the device.""" + if self._attr_is_on: + return MediaPlayerState.ON + return MediaPlayerState.OFF + + async def async_turn_on(self) -> None: + """Turn the Android TV on.""" + if not self._attr_is_on: + self._send_key_command("POWER") + + async def async_turn_off(self) -> None: + """Turn the Android TV off.""" + if self._attr_is_on: + self._send_key_command("POWER") + + async def async_volume_up(self) -> None: + """Turn volume up for media player.""" + self._send_key_command("VOLUME_UP") + + async def async_volume_down(self) -> None: + """Turn volume down for media player.""" + self._send_key_command("VOLUME_DOWN") + + async def async_mute_volume(self, mute: bool) -> None: + """Mute the volume.""" + if mute != self.is_volume_muted: + self._send_key_command("VOLUME_MUTE") + + async def async_media_play(self) -> None: + """Send play command.""" + self._send_key_command("MEDIA_PLAY") + + async def async_media_pause(self) -> None: + """Send pause command.""" + self._send_key_command("MEDIA_PAUSE") + + async def async_media_play_pause(self) -> None: + """Send play/pause command.""" + self._send_key_command("MEDIA_PLAY_PAUSE") + + async def async_media_stop(self) -> None: + """Send stop command.""" + self._send_key_command("MEDIA_STOP") + + async def async_media_previous_track(self) -> None: + """Send previous track command.""" + self._send_key_command("MEDIA_PREVIOUS") + + async def async_media_next_track(self) -> None: + """Send next track command.""" + self._send_key_command("MEDIA_NEXT") + + async def async_play_media( + self, media_type: MediaType | str, media_id: str, **kwargs: Any + ) -> None: + """Play a piece of media.""" + if media_type == MediaType.CHANNEL: + if not media_id.isnumeric(): + raise ValueError(f"Channel must be numeric: {media_id}") + if self._channel_set_task: + self._channel_set_task.cancel() + self._channel_set_task = asyncio.create_task( + self._send_key_commands(list(media_id)) + ) + await self._channel_set_task + return + + if media_type == MediaType.URL: + self._send_launch_app_command(media_id) + return + + raise ValueError(f"Invalid media type: {media_type}") + + async def _send_key_commands( + self, key_codes: list[str], delay_secs: float = 0.1 + ) -> None: + """Send a key press sequence to Android TV. + + The delay is necessary because device may ignore + some commands if we send the sequence without delay. + """ + try: + for key_code in key_codes: + self._api.send_key_command(key_code) + await asyncio.sleep(delay_secs) + except ConnectionClosed as exc: + raise HomeAssistantError( + "Connection to Android TV device is closed" + ) from exc diff --git a/homeassistant/components/androidtv_remote/remote.py b/homeassistant/components/androidtv_remote/remote.py index 1c68c92bc684..f4c2ae51ce1e 100644 --- a/homeassistant/components/androidtv_remote/remote.py +++ b/homeassistant/components/androidtv_remote/remote.py @@ -3,10 +3,9 @@ from __future__ import annotations import asyncio from collections.abc import Iterable -import logging from typing import Any -from androidtvremote2 import AndroidTVRemote, ConnectionClosed +from androidtvremote2 import AndroidTVRemote from homeassistant.components.remote import ( ATTR_ACTIVITY, @@ -20,17 +19,13 @@ from homeassistant.components.remote import ( RemoteEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN +from .entity import AndroidTVRemoteBaseEntity PARALLEL_UPDATES = 0 -_LOGGER = logging.getLogger(__name__) async def async_setup_entry( @@ -43,62 +38,29 @@ async def async_setup_entry( async_add_entities([AndroidTVRemoteEntity(api, config_entry)]) -class AndroidTVRemoteEntity(RemoteEntity): - """Representation of an Android TV Remote.""" +class AndroidTVRemoteEntity(AndroidTVRemoteBaseEntity, RemoteEntity): + """Android TV Remote Entity.""" - _attr_has_entity_name = True - _attr_should_poll = False + _attr_supported_features = RemoteEntityFeature.ACTIVITY - def __init__(self, api: AndroidTVRemote, config_entry: ConfigEntry) -> None: - """Initialize device.""" - self._api = api - self._host = config_entry.data[CONF_HOST] - self._name = config_entry.data[CONF_NAME] - self._attr_unique_id = config_entry.unique_id - self._attr_supported_features = RemoteEntityFeature.ACTIVITY - self._attr_is_on = api.is_on - self._attr_current_activity = api.current_app - device_info = api.device_info - assert config_entry.unique_id - assert device_info - self._attr_device_info = DeviceInfo( - connections={(CONNECTION_NETWORK_MAC, config_entry.data[CONF_MAC])}, - identifiers={(DOMAIN, config_entry.unique_id)}, - name=self._name, - manufacturer=device_info["manufacturer"], - model=device_info["model"], - ) + @callback + def _current_app_updated(self, current_app: str) -> None: + """Update the state when the current app changes.""" + self._attr_current_activity = current_app + self.async_write_ha_state() - @callback - def is_on_updated(is_on: bool) -> None: - self._attr_is_on = is_on - self.async_write_ha_state() + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + await super().async_added_to_hass() - @callback - def current_app_updated(current_app: str) -> None: - self._attr_current_activity = current_app - self.async_write_ha_state() + self._attr_current_activity = self._api.current_app + self._api.add_current_app_updated_callback(self._current_app_updated) - @callback - def is_available_updated(is_available: bool) -> None: - if is_available: - _LOGGER.info( - "Reconnected to %s at %s", - self._name, - self._host, - ) - else: - _LOGGER.warning( - "Disconnected from %s at %s", - self._name, - self._host, - ) - self._attr_available = is_available - self.async_write_ha_state() + async def async_will_remove_from_hass(self) -> None: + """Remove callbacks.""" + await super().async_will_remove_from_hass() - api.add_is_on_updated_callback(is_on_updated) - api.add_current_app_updated_callback(current_app_updated) - api.add_is_available_updated_callback(is_available_updated) + self._api.remove_current_app_updated_callback(self._current_app_updated) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the Android TV on.""" @@ -128,27 +90,3 @@ class AndroidTVRemoteEntity(RemoteEntity): else: self._send_key_command(single_command, "SHORT") await asyncio.sleep(delay_secs) - - def _send_key_command(self, key_code: str, direction: str = "SHORT") -> None: - """Send a key press to Android TV. - - This does not block; it buffers the data and arranges for it to be sent out asynchronously. - """ - try: - self._api.send_key_command(key_code, direction) - except ConnectionClosed as exc: - raise HomeAssistantError( - "Connection to Android TV device is closed" - ) from exc - - def _send_launch_app_command(self, app_link: str) -> None: - """Launch an app on Android TV. - - This does not block; it buffers the data and arranges for it to be sent out asynchronously. - """ - try: - self._api.send_launch_app_command(app_link) - except ConnectionClosed as exc: - raise HomeAssistantError( - "Connection to Android TV device is closed" - ) from exc diff --git a/requirements_all.txt b/requirements_all.txt index e39f186fcb1d..37d9a6a364e1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -333,7 +333,7 @@ amcrest==1.9.7 androidtv[async]==0.0.70 # homeassistant.components.androidtv_remote -androidtvremote2==0.0.7 +androidtvremote2==0.0.8 # homeassistant.components.anel_pwrctrl anel_pwrctrl-homeassistant==0.0.1.dev2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c08de372edd8..79ccc0a16842 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -308,7 +308,7 @@ ambiclimate==0.2.1 androidtv[async]==0.0.70 # homeassistant.components.androidtv_remote -androidtvremote2==0.0.7 +androidtvremote2==0.0.8 # homeassistant.components.anova anova-wifi==0.9.0 diff --git a/tests/components/androidtv_remote/conftest.py b/tests/components/androidtv_remote/conftest.py index ffe9d8b8dbe9..b981581becdc 100644 --- a/tests/components/androidtv_remote/conftest.py +++ b/tests/components/androidtv_remote/conftest.py @@ -1,5 +1,5 @@ """Fixtures for the Android TV Remote integration tests.""" -from collections.abc import Generator +from collections.abc import Callable, Generator from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -42,6 +42,57 @@ def mock_api() -> Generator[None, MagicMock, None]: "manufacturer": "My Android TV manufacturer", "model": "My Android TV model", } + + is_on_updated_callbacks: list[Callable] = [] + current_app_updated_callbacks: list[Callable] = [] + volume_info_updated_callbacks: list[Callable] = [] + is_available_updated_callbacks: list[Callable] = [] + + def mocked_add_is_on_updated_callback(callback: Callable): + is_on_updated_callbacks.append(callback) + + def mocked_add_current_app_updated_callback(callback: Callable): + current_app_updated_callbacks.append(callback) + + def mocked_add_volume_info_updated_callback(callback: Callable): + volume_info_updated_callbacks.append(callback) + + def mocked_add_is_available_updated_callbacks(callback: Callable): + is_available_updated_callbacks.append(callback) + + def mocked_is_on_updated(is_on: bool): + for callback in is_on_updated_callbacks: + callback(is_on) + + def mocked_current_app_updated(current_app: str): + for callback in current_app_updated_callbacks: + callback(current_app) + + def mocked_volume_info_updated(volume_info: dict[str, str | bool]): + for callback in volume_info_updated_callbacks: + callback(volume_info) + + def mocked_is_available_updated(is_available: bool): + for callback in is_available_updated_callbacks: + callback(is_available) + + mock_api.add_is_on_updated_callback.side_effect = ( + mocked_add_is_on_updated_callback + ) + mock_api.add_current_app_updated_callback.side_effect = ( + mocked_add_current_app_updated_callback + ) + mock_api.add_volume_info_updated_callback.side_effect = ( + mocked_add_volume_info_updated_callback + ) + mock_api.add_is_available_updated_callback.side_effect = ( + mocked_add_is_available_updated_callbacks + ) + mock_api._on_is_on_updated.side_effect = mocked_is_on_updated + mock_api._on_current_app_updated.side_effect = mocked_current_app_updated + mock_api._on_volume_info_updated.side_effect = mocked_volume_info_updated + mock_api._on_is_available_updated.side_effect = mocked_is_available_updated + yield mock_api diff --git a/tests/components/androidtv_remote/test_media_player.py b/tests/components/androidtv_remote/test_media_player.py new file mode 100644 index 000000000000..c716b0f8689c --- /dev/null +++ b/tests/components/androidtv_remote/test_media_player.py @@ -0,0 +1,314 @@ +"""Tests for the Android TV Remote remote platform.""" +from unittest.mock import MagicMock, call + +from androidtvremote2 import ConnectionClosed +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from tests.common import MockConfigEntry + +MEDIA_PLAYER_ENTITY = "media_player.my_android_tv" + + +async def test_media_player_receives_push_updates( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock +) -> None: + """Test the Android TV Remote media player receives push updates and state is updated.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.LOADED + + mock_api._on_is_on_updated(False) + assert hass.states.is_state(MEDIA_PLAYER_ENTITY, STATE_OFF) + + mock_api._on_is_on_updated(True) + assert hass.states.is_state(MEDIA_PLAYER_ENTITY, STATE_ON) + + mock_api._on_current_app_updated("com.google.android.tvlauncher") + assert ( + hass.states.get(MEDIA_PLAYER_ENTITY).attributes.get("app_id") + == "com.google.android.tvlauncher" + ) + assert ( + hass.states.get(MEDIA_PLAYER_ENTITY).attributes.get("app_name") + == "com.google.android.tvlauncher" + ) + + mock_api._on_volume_info_updated({"level": 35, "muted": False, "max": 100}) + assert hass.states.get(MEDIA_PLAYER_ENTITY).attributes.get("volume_level") == 0.35 + + mock_api._on_volume_info_updated({"level": 50, "muted": True, "max": 100}) + assert hass.states.get(MEDIA_PLAYER_ENTITY).attributes.get("volume_level") == 0.50 + assert hass.states.get(MEDIA_PLAYER_ENTITY).attributes.get("is_volume_muted") + + mock_api._on_volume_info_updated({"level": 0, "muted": False, "max": 0}) + assert hass.states.get(MEDIA_PLAYER_ENTITY).attributes.get("volume_level") is None + assert ( + hass.states.get(MEDIA_PLAYER_ENTITY).attributes.get("is_volume_muted") is None + ) + + mock_api._on_is_available_updated(False) + assert hass.states.is_state(MEDIA_PLAYER_ENTITY, STATE_UNAVAILABLE) + + mock_api._on_is_available_updated(True) + assert hass.states.is_state(MEDIA_PLAYER_ENTITY, STATE_ON) + + +async def test_media_player_toggles( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock +) -> None: + """Test the Android TV Remote media player toggles.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert await hass.services.async_call( + "media_player", + "turn_off", + {"entity_id": MEDIA_PLAYER_ENTITY}, + blocking=True, + ) + mock_api._on_is_on_updated(False) + + mock_api.send_key_command.assert_called_with("POWER", "SHORT") + + assert await hass.services.async_call( + "media_player", + "turn_on", + {"entity_id": MEDIA_PLAYER_ENTITY}, + blocking=True, + ) + mock_api._on_is_on_updated(True) + + mock_api.send_key_command.assert_called_with("POWER", "SHORT") + + +async def test_media_player_volume( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock +) -> None: + """Test the Android TV Remote media player up/down/mute volume.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert await hass.services.async_call( + "media_player", + "volume_up", + {"entity_id": MEDIA_PLAYER_ENTITY}, + blocking=True, + ) + mock_api._on_volume_info_updated({"level": 10, "muted": False, "max": 100}) + + mock_api.send_key_command.assert_called_with("VOLUME_UP", "SHORT") + + assert await hass.services.async_call( + "media_player", + "volume_down", + {"entity_id": MEDIA_PLAYER_ENTITY}, + blocking=True, + ) + mock_api._on_volume_info_updated({"level": 9, "muted": False, "max": 100}) + + mock_api.send_key_command.assert_called_with("VOLUME_DOWN", "SHORT") + + assert await hass.services.async_call( + "media_player", + "volume_mute", + {"entity_id": MEDIA_PLAYER_ENTITY, "is_volume_muted": True}, + blocking=True, + ) + mock_api._on_volume_info_updated({"level": 9, "muted": True, "max": 100}) + + mock_api.send_key_command.assert_called_with("VOLUME_MUTE", "SHORT") + + assert await hass.services.async_call( + "media_player", + "volume_mute", + {"entity_id": MEDIA_PLAYER_ENTITY, "is_volume_muted": False}, + blocking=True, + ) + mock_api._on_volume_info_updated({"level": 9, "muted": False, "max": 100}) + + mock_api.send_key_command.assert_called_with("VOLUME_MUTE", "SHORT") + + +async def test_media_player_controls( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock +) -> None: + """Test the Android TV Remote media player play/pause/stop/next/prev.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert await hass.services.async_call( + "media_player", + "media_play", + {"entity_id": MEDIA_PLAYER_ENTITY}, + blocking=True, + ) + + mock_api.send_key_command.assert_called_with("MEDIA_PLAY", "SHORT") + + assert await hass.services.async_call( + "media_player", + "media_pause", + {"entity_id": MEDIA_PLAYER_ENTITY}, + blocking=True, + ) + + mock_api.send_key_command.assert_called_with("MEDIA_PAUSE", "SHORT") + + assert await hass.services.async_call( + "media_player", + "media_play_pause", + {"entity_id": MEDIA_PLAYER_ENTITY}, + blocking=True, + ) + + mock_api.send_key_command.assert_called_with("MEDIA_PLAY_PAUSE", "SHORT") + + assert await hass.services.async_call( + "media_player", + "media_stop", + {"entity_id": MEDIA_PLAYER_ENTITY}, + blocking=True, + ) + + mock_api.send_key_command.assert_called_with("MEDIA_STOP", "SHORT") + + assert await hass.services.async_call( + "media_player", + "media_previous_track", + {"entity_id": MEDIA_PLAYER_ENTITY}, + blocking=True, + ) + + mock_api.send_key_command.assert_called_with("MEDIA_PREVIOUS", "SHORT") + + assert await hass.services.async_call( + "media_player", + "media_next_track", + {"entity_id": MEDIA_PLAYER_ENTITY}, + blocking=True, + ) + + mock_api.send_key_command.assert_called_with("MEDIA_NEXT", "SHORT") + + +async def test_media_player_play_media( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock +) -> None: + """Test the Android TV Remote media player play_media.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert await hass.services.async_call( + "media_player", + "play_media", + { + "entity_id": MEDIA_PLAYER_ENTITY, + "media_content_type": "channel", + "media_content_id": "45", + }, + blocking=True, + ) + assert mock_api.send_key_command.mock_calls == [ + call("4"), + call("5"), + ] + + # Test that set channel task has been canceled + mock_api.send_key_command.reset_mock() + await hass.services.async_call( + "media_player", + "play_media", + { + "entity_id": MEDIA_PLAYER_ENTITY, + "media_content_type": "channel", + "media_content_id": "7777", + }, + blocking=False, + ) + await hass.services.async_call( + "media_player", + "play_media", + { + "entity_id": MEDIA_PLAYER_ENTITY, + "media_content_type": "channel", + "media_content_id": "11", + }, + blocking=True, + ) + assert mock_api.send_key_command.call_count == 2 + + assert await hass.services.async_call( + "media_player", + "play_media", + { + "entity_id": MEDIA_PLAYER_ENTITY, + "media_content_type": "url", + "media_content_id": "https://www.youtube.com", + }, + blocking=True, + ) + mock_api.send_launch_app_command.assert_called_with("https://www.youtube.com") + + with pytest.raises(ValueError): + assert await hass.services.async_call( + "media_player", + "play_media", + { + "entity_id": MEDIA_PLAYER_ENTITY, + "media_content_type": "channel", + "media_content_id": "abc", + }, + blocking=True, + ) + + with pytest.raises(ValueError): + assert await hass.services.async_call( + "media_player", + "play_media", + { + "entity_id": MEDIA_PLAYER_ENTITY, + "media_content_type": "music", + "media_content_id": "invalid", + }, + blocking=True, + ) + + +async def test_media_player_connection_closed( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock +) -> None: + """Test media_player raise HomeAssistantError if ConnectionClosed.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.LOADED + + mock_api.send_key_command.side_effect = ConnectionClosed() + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "media_player", + "media_pause", + {"entity_id": MEDIA_PLAYER_ENTITY}, + blocking=True, + ) + + mock_api.send_launch_app_command.side_effect = ConnectionClosed() + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "media_player", + "play_media", + { + "entity_id": MEDIA_PLAYER_ENTITY, + "media_content_type": "channel", + "media_content_id": "1", + }, + blocking=True, + ) diff --git a/tests/components/androidtv_remote/test_remote.py b/tests/components/androidtv_remote/test_remote.py index d0372b8a65a4..cc1d8973d494 100644 --- a/tests/components/androidtv_remote/test_remote.py +++ b/tests/components/androidtv_remote/test_remote.py @@ -1,5 +1,4 @@ """Tests for the Android TV Remote remote platform.""" -from collections.abc import Callable from unittest.mock import MagicMock, call from androidtvremote2 import ConnectionClosed @@ -19,49 +18,25 @@ async def test_remote_receives_push_updates( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock ) -> None: """Test the Android TV Remote receives push updates and state is updated.""" - is_on_updated_callback: Callable | None = None - current_app_updated_callback: Callable | None = None - is_available_updated_callback: Callable | None = None - - def mocked_add_is_on_updated_callback(callback: Callable): - nonlocal is_on_updated_callback - is_on_updated_callback = callback - - def mocked_add_current_app_updated_callback(callback: Callable): - nonlocal current_app_updated_callback - current_app_updated_callback = callback - - def mocked_add_is_available_updated_callback(callback: Callable): - nonlocal is_available_updated_callback - is_available_updated_callback = callback - - mock_api.add_is_on_updated_callback.side_effect = mocked_add_is_on_updated_callback - mock_api.add_current_app_updated_callback.side_effect = ( - mocked_add_current_app_updated_callback - ) - mock_api.add_is_available_updated_callback.side_effect = ( - mocked_add_is_available_updated_callback - ) - mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) assert mock_config_entry.state is ConfigEntryState.LOADED - is_on_updated_callback(False) + mock_api._on_is_on_updated(False) assert hass.states.is_state(REMOTE_ENTITY, STATE_OFF) - is_on_updated_callback(True) + mock_api._on_is_on_updated(True) assert hass.states.is_state(REMOTE_ENTITY, STATE_ON) - current_app_updated_callback("activity1") + mock_api._on_current_app_updated("activity1") assert ( hass.states.get(REMOTE_ENTITY).attributes.get("current_activity") == "activity1" ) - is_available_updated_callback(False) + mock_api._on_is_available_updated(False) assert hass.states.is_state(REMOTE_ENTITY, STATE_UNAVAILABLE) - is_available_updated_callback(True) + mock_api._on_is_available_updated(True) assert hass.states.is_state(REMOTE_ENTITY, STATE_ON) @@ -69,14 +44,6 @@ async def test_remote_toggles( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock ) -> None: """Test the Android TV Remote toggles.""" - is_on_updated_callback: Callable | None = None - - def mocked_add_is_on_updated_callback(callback: Callable): - nonlocal is_on_updated_callback - is_on_updated_callback = callback - - mock_api.add_is_on_updated_callback.side_effect = mocked_add_is_on_updated_callback - mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) assert mock_config_entry.state is ConfigEntryState.LOADED @@ -87,7 +54,7 @@ async def test_remote_toggles( {"entity_id": REMOTE_ENTITY}, blocking=True, ) - is_on_updated_callback(False) + mock_api._on_is_on_updated(False) mock_api.send_key_command.assert_called_with("POWER", "SHORT") @@ -97,7 +64,7 @@ async def test_remote_toggles( {"entity_id": REMOTE_ENTITY}, blocking=True, ) - is_on_updated_callback(True) + mock_api._on_is_on_updated(True) mock_api.send_key_command.assert_called_with("POWER", "SHORT") assert mock_api.send_key_command.call_count == 2 From 4c3e98b77c171cb9905349f26d82de7d3b1ec2e4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 6 May 2023 09:46:00 -0500 Subject: [PATCH 238/822] Fix parallel_updates being acquired too late for entity executor jobs (#92681) * Fix parallel_updates being acquired too late for entity executor jobs * tweak --- homeassistant/helpers/entity.py | 15 +++++++------- tests/helpers/test_entity.py | 35 +++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 9dbd5d4ad67e..001713505941 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -763,13 +763,6 @@ class Entity(ABC): hass = self.hass assert hass is not None - if hasattr(self, "async_update"): - coro: asyncio.Future[None] = self.async_update() - elif hasattr(self, "update"): - coro = hass.async_add_executor_job(self.update) - else: - return - self._update_staged = True # Process update sequential @@ -780,8 +773,14 @@ class Entity(ABC): update_warn = hass.loop.call_later( SLOW_UPDATE_WARNING, self._async_slow_update_warning ) + try: - await coro + if hasattr(self, "async_update"): + await self.async_update() + elif hasattr(self, "update"): + await hass.async_add_executor_job(self.update) + else: + return finally: self._update_staged = False if warning: diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index bb95860142de..40c402f6f49b 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -531,6 +531,41 @@ async def test_async_parallel_updates_with_two(hass: HomeAssistant) -> None: test_lock.release() +async def test_async_parallel_updates_with_one_using_executor( + hass: HomeAssistant, +) -> None: + """Test parallel updates with 1 (sequential) using the executor.""" + test_semaphore = asyncio.Semaphore(1) + locked = [] + + class SyncEntity(entity.Entity): + """Test entity.""" + + def __init__(self, entity_id): + """Initialize sync test entity.""" + self.entity_id = entity_id + self.hass = hass + self.parallel_updates = test_semaphore + + def update(self): + """Test update.""" + locked.append(self.parallel_updates.locked()) + + entities = [SyncEntity(f"sensor.test_{i}") for i in range(3)] + + await asyncio.gather( + *[ + hass.async_create_task( + ent.async_update_ha_state(True), + f"Entity schedule update ha state {ent.entity_id}", + ) + for ent in entities + ] + ) + + assert locked == [True, True, True] + + async def test_async_remove_no_platform(hass: HomeAssistant) -> None: """Test async_remove method when no platform set.""" ent = entity.Entity() From 080ce51ed991e9202db04a60cf4c65c887bc2be4 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 6 May 2023 16:49:34 +0200 Subject: [PATCH 239/822] Remove issue from pushover (#92595) * Remove issue pushover * Remove strings --- homeassistant/components/pushover/notify.py | 20 ------------ .../components/pushover/strings.json | 6 ---- tests/components/pushover/test_init.py | 31 ------------------- 3 files changed, 57 deletions(-) diff --git a/homeassistant/components/pushover/notify.py b/homeassistant/components/pushover/notify.py index 2d79a0bf65ab..52eaed227e5a 100644 --- a/homeassistant/components/pushover/notify.py +++ b/homeassistant/components/pushover/notify.py @@ -5,21 +5,16 @@ import logging from typing import Any from pushover_complete import BadAPIRequestError, PushoverAPI -import voluptuous as vol from homeassistant.components.notify import ( ATTR_DATA, ATTR_TARGET, ATTR_TITLE, ATTR_TITLE_DEFAULT, - PLATFORM_SCHEMA, BaseNotificationService, ) -from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ( @@ -40,11 +35,6 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - {vol.Required(CONF_USER_KEY): cv.string, vol.Required(CONF_API_KEY): cv.string} -) - - async def async_get_service( hass: HomeAssistant, config: ConfigType, @@ -52,17 +42,7 @@ async def async_get_service( ) -> PushoverNotificationService | None: """Get the Pushover notification service.""" if discovery_info is None: - async_create_issue( - hass, - DOMAIN, - "removed_yaml", - breaks_in_ha_version="2022.11.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="removed_yaml", - ) return None - pushover_api: PushoverAPI = hass.data[DOMAIN][discovery_info["entry_id"]] return PushoverNotificationService( hass, pushover_api, discovery_info[CONF_USER_KEY] diff --git a/homeassistant/components/pushover/strings.json b/homeassistant/components/pushover/strings.json index 3c2ab66bf399..cd72a9c20a28 100644 --- a/homeassistant/components/pushover/strings.json +++ b/homeassistant/components/pushover/strings.json @@ -24,11 +24,5 @@ } } } - }, - "issues": { - "removed_yaml": { - "title": "The Pushover YAML configuration has been removed", - "description": "Configuring Pushover using YAML has been removed.\n\nYour existing YAML configuration is not used by Home Assistant.\n\nRemove the Pushover YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } } } diff --git a/tests/components/pushover/test_init.py b/tests/components/pushover/test_init.py index ef1413e40854..261426345d1d 100644 --- a/tests/components/pushover/test_init.py +++ b/tests/components/pushover/test_init.py @@ -5,17 +5,13 @@ from pushover_complete import BadAPIRequestError import pytest import requests_mock -from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN from homeassistant.components.pushover.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from . import MOCK_CONFIG from tests.common import MockConfigEntry -from tests.components.repairs import get_repairs -from tests.typing import WebSocketGenerator @pytest.fixture(autouse=False) @@ -27,33 +23,6 @@ def mock_pushover(): yield mock_generic_post -async def test_setup( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - mock_pushover: MagicMock, -) -> None: - """Test integration failed due to an error.""" - assert await async_setup_component( - hass, - NOTIFY_DOMAIN, - { - NOTIFY_DOMAIN: [ - { - "name": "Pushover", - "platform": "pushover", - "api_key": "MYAPIKEY", - "user_key": "MYUSERKEY", - } - ] - }, - ) - await hass.async_block_till_done() - assert not hass.config_entries.async_entries(DOMAIN) - issues = await get_repairs(hass, hass_ws_client) - assert len(issues) == 1 - assert issues[0]["issue_id"] == "removed_yaml" - - async def test_async_setup_entry_success( hass: HomeAssistant, mock_pushover: MagicMock ) -> None: From 3f8ffcae735afa714c93bb782b40343d9a8debd3 Mon Sep 17 00:00:00 2001 From: Luke Date: Sat, 6 May 2023 12:32:44 -0400 Subject: [PATCH 240/822] Bump anova_wifi to 0.10.0 to add better debug (#92686) add more debig --- homeassistant/components/anova/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/anova/manifest.json b/homeassistant/components/anova/manifest.json index 39608011a03c..7c4509e2f256 100644 --- a/homeassistant/components/anova/manifest.json +++ b/homeassistant/components/anova/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/anova", "iot_class": "cloud_polling", "loggers": ["anova_wifi"], - "requirements": ["anova-wifi==0.9.0"] + "requirements": ["anova-wifi==0.10.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 37d9a6a364e1..0828e993a9f3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -339,7 +339,7 @@ androidtvremote2==0.0.8 anel_pwrctrl-homeassistant==0.0.1.dev2 # homeassistant.components.anova -anova-wifi==0.9.0 +anova-wifi==0.10.0 # homeassistant.components.anthemav anthemav==1.4.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 79ccc0a16842..43bffa9e6744 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -311,7 +311,7 @@ androidtv[async]==0.0.70 androidtvremote2==0.0.8 # homeassistant.components.anova -anova-wifi==0.9.0 +anova-wifi==0.10.0 # homeassistant.components.anthemav anthemav==1.4.1 From 9538d8bcfe6a73166a63f8237448d20d62873a8a Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 6 May 2023 19:02:09 +0200 Subject: [PATCH 241/822] Bump pysensibo to 1.0.28 (#92684) --- homeassistant/components/sensibo/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensibo/manifest.json b/homeassistant/components/sensibo/manifest.json index 729299bb8fa0..f99792f7dc1d 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.25"] + "requirements": ["pysensibo==1.0.28"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0828e993a9f3..fa2ef680ed16 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1934,7 +1934,7 @@ pysaj==0.0.16 pysdcp==1 # homeassistant.components.sensibo -pysensibo==1.0.25 +pysensibo==1.0.28 # homeassistant.components.serial # homeassistant.components.zha diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 43bffa9e6744..5f8b6b250945 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1417,7 +1417,7 @@ pyrympro==0.0.7 pysabnzbd==1.1.1 # homeassistant.components.sensibo -pysensibo==1.0.25 +pysensibo==1.0.28 # homeassistant.components.serial # homeassistant.components.zha From c634d8f940c51dc9699779017a5a3daf2e9aea11 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Sat, 6 May 2023 12:11:57 -0600 Subject: [PATCH 242/822] Bump `aionotion` to 2023.05.1 (#92697) --- homeassistant/components/notion/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/notion/manifest.json b/homeassistant/components/notion/manifest.json index 1c3ffc8607ae..f0952089ba82 100644 --- a/homeassistant/components/notion/manifest.json +++ b/homeassistant/components/notion/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["aionotion"], - "requirements": ["aionotion==2023.05.0"] + "requirements": ["aionotion==2023.05.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index fa2ef680ed16..f4135294b534 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -223,7 +223,7 @@ aionanoleaf==0.2.1 aionotify==0.2.0 # homeassistant.components.notion -aionotion==2023.05.0 +aionotion==2023.05.1 # homeassistant.components.oncue aiooncue==0.3.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5f8b6b250945..f5bae995eda1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -204,7 +204,7 @@ aiomusiccast==0.14.8 aionanoleaf==0.2.1 # homeassistant.components.notion -aionotion==2023.05.0 +aionotion==2023.05.1 # homeassistant.components.oncue aiooncue==0.3.4 From 14fd5b7cda65be8df7dd85de950ad14b3c168e15 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 6 May 2023 23:21:31 +0200 Subject: [PATCH 243/822] Update pylint to 2.17.4 (#92703) --- requirements_test.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements_test.txt b/requirements_test.txt index 1ed6c5c5215b..2b0cf62bbfdc 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.3 +astroid==2.15.4 coverage==7.2.4 freezegun==1.2.2 mock-open==1.4.0 mypy==1.2.0 pre-commit==3.1.0 pydantic==1.10.7 -pylint==2.17.2 +pylint==2.17.4 pylint-per-file-ignores==1.1.0 pipdeptree==2.7.0 pytest-asyncio==0.20.3 From d66305ddd3ca175d5d48e7454c285c9d6509de1d Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 7 May 2023 00:01:58 +0200 Subject: [PATCH 244/822] Remove dlink yaml import (#92590) --- homeassistant/components/dlink/config_flow.py | 11 +--- homeassistant/components/dlink/switch.py | 65 ++----------------- homeassistant/components/lyric/strings.json | 6 -- tests/components/dlink/test_config_flow.py | 16 +---- tests/components/dlink/test_switch.py | 51 ++++++--------- 5 files changed, 27 insertions(+), 122 deletions(-) diff --git a/homeassistant/components/dlink/config_flow.py b/homeassistant/components/dlink/config_flow.py index 4499e2efffcc..09df5571a787 100644 --- a/homeassistant/components/dlink/config_flow.py +++ b/homeassistant/components/dlink/config_flow.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import dhcp -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.data_entry_flow import FlowResult from .const import CONF_USE_LEGACY_PROTOCOL, DEFAULT_NAME, DEFAULT_USERNAME, DOMAIN @@ -72,15 +72,6 @@ class DLinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_import(self, config: dict[str, Any]) -> FlowResult: - """Import a config entry.""" - self._async_abort_entries_match({CONF_HOST: config[CONF_HOST]}) - title = config.pop(CONF_NAME, DEFAULT_NAME) - return self.async_create_entry( - title=title, - data=config, - ) - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: diff --git a/homeassistant/components/dlink/switch.py b/homeassistant/components/dlink/switch.py index e6b9a4c7883e..d06372bb28b3 100644 --- a/homeassistant/components/dlink/switch.py +++ b/homeassistant/components/dlink/switch.py @@ -4,80 +4,23 @@ from __future__ import annotations from datetime import timedelta from typing import Any -import voluptuous as vol - -from homeassistant.components.switch import ( - PLATFORM_SCHEMA, - SwitchEntity, - SwitchEntityDescription, -) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import ( - ATTR_TEMPERATURE, - CONF_HOST, - CONF_NAME, - CONF_PASSWORD, - CONF_USERNAME, - UnitOfTemperature, -) +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature 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 ( - ATTR_TOTAL_CONSUMPTION, - CONF_USE_LEGACY_PROTOCOL, - DEFAULT_NAME, - DEFAULT_USERNAME, - DOMAIN, -) +from .const import ATTR_TOTAL_CONSUMPTION, DOMAIN from .entity import DLinkEntity SCAN_INTERVAL = timedelta(minutes=2) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_PASSWORD, default=""): cv.string, - vol.Required(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_USE_LEGACY_PROTOCOL, default=False): cv.boolean, - } -) - SWITCH_TYPE = SwitchEntityDescription( key="switch", name="Switch", ) -def setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up a D-Link Smart Plug.""" - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml", - breaks_in_ha_version="2023.4.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - ) - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config, - ) - ) - - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: diff --git a/homeassistant/components/lyric/strings.json b/homeassistant/components/lyric/strings.json index dd9a89f294d5..3c9cd6043dfa 100644 --- a/homeassistant/components/lyric/strings.json +++ b/homeassistant/components/lyric/strings.json @@ -17,11 +17,5 @@ "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" } - }, - "issues": { - "removed_yaml": { - "title": "The Honeywell Lyric YAML configuration has been removed", - "description": "Configuring Honeywell Lyric using YAML has been removed.\n\nYour existing YAML configuration is not used by Home Assistant.\n\nRemove the YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } } } diff --git a/tests/components/dlink/test_config_flow.py b/tests/components/dlink/test_config_flow.py index e9ee29134aaf..5cd34987646a 100644 --- a/tests/components/dlink/test_config_flow.py +++ b/tests/components/dlink/test_config_flow.py @@ -4,7 +4,7 @@ from unittest.mock import MagicMock, patch from homeassistant import data_entry_flow from homeassistant.components import dhcp from homeassistant.components.dlink.const import DEFAULT_NAME, DOMAIN -from homeassistant.config_entries import SOURCE_DHCP, SOURCE_IMPORT, SOURCE_USER +from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant @@ -13,7 +13,6 @@ from .conftest import ( CONF_DHCP_DATA, CONF_DHCP_FLOW, CONF_DHCP_FLOW_NEW_IP, - CONF_IMPORT_DATA, patch_config_flow, ) @@ -99,19 +98,6 @@ async def test_flow_user_unknown_error( assert result["data"] == CONF_DATA -async def test_import(hass: HomeAssistant, mocked_plug: MagicMock) -> None: - """Test import initialized flow.""" - with patch_config_flow(mocked_plug), _patch_setup_entry(): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=CONF_IMPORT_DATA, - ) - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert result["title"] == "Smart Plug" - assert result["data"] == CONF_DATA - - async def test_dhcp(hass: HomeAssistant, mocked_plug: MagicMock) -> None: """Test we can process the discovery from dhcp.""" result = await hass.config_entries.flow.async_init( diff --git a/tests/components/dlink/test_switch.py b/tests/components/dlink/test_switch.py index 683d30be7f64..24316006b5e9 100644 --- a/tests/components/dlink/test_switch.py +++ b/tests/components/dlink/test_switch.py @@ -1,4 +1,5 @@ """Switch tests for the D-Link Smart Plug integration.""" +from unittest.mock import patch from homeassistant.components.dlink import DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN @@ -10,39 +11,22 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component -from .conftest import ComponentSetup +from .conftest import CONF_DATA -from tests.components.repairs import get_repairs -from tests.typing import WebSocketGenerator +from tests.common import AsyncMock, MockConfigEntry -async def test_switch_state( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - setup_integration: ComponentSetup, -) -> None: +async def test_switch_state(hass: HomeAssistant, mocked_plug: AsyncMock) -> None: """Test we get the switch status.""" - assert await async_setup_component( - hass, - SWITCH_DOMAIN, - { - SWITCH_DOMAIN: { - "platform": DOMAIN, - "host": "1.2.3.4", - "username": "admin", - "password": "123456", - "use_legacy_protocol": True, - } - }, - ) - await hass.async_block_till_done() - issues = await get_repairs(hass, hass_ws_client) - assert len(issues) == 1 - assert issues[0]["issue_id"] == "deprecated_yaml" - - await setup_integration() + with patch( + "homeassistant.components.dlink.SmartPlug", + return_value=mocked_plug, + ): + entry = MockConfigEntry(domain=DOMAIN, data=CONF_DATA) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() entity_id = "switch.mock_title_switch" state = hass.states.get(entity_id) @@ -66,10 +50,17 @@ async def test_switch_state( async def test_switch_no_value( - hass: HomeAssistant, setup_integration_legacy: ComponentSetup + hass: HomeAssistant, mocked_plug_legacy: AsyncMock ) -> None: """Test we handle 'N/A' being passed by the pypi package.""" - await setup_integration_legacy() + with patch( + "homeassistant.components.dlink.SmartPlug", + return_value=mocked_plug_legacy, + ): + entry = MockConfigEntry(domain=DOMAIN, data=CONF_DATA) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() state = hass.states.get("switch.mock_title_switch") assert state.state == STATE_OFF From d05724a42ab3488eae65ef9580656508aff879bf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 6 May 2023 17:12:24 -0500 Subject: [PATCH 245/822] Fix onvif reauth when device returns a http 401/403 error (#92690) --- homeassistant/components/onvif/__init__.py | 16 +++++++++++++++- homeassistant/components/onvif/config_flow.py | 6 +++++- homeassistant/components/onvif/strings.json | 1 + tests/components/onvif/test_config_flow.py | 14 +++++++++++++- 4 files changed, 34 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/onvif/__init__.py b/homeassistant/components/onvif/__init__.py index 2c96b79cbeba..a7c23064f645 100644 --- a/homeassistant/components/onvif/__init__.py +++ b/homeassistant/components/onvif/__init__.py @@ -1,5 +1,6 @@ """The ONVIF integration.""" import asyncio +from http import HTTPStatus import logging from httpx import RequestError @@ -56,7 +57,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except ONVIFError as err: await device.device.close() raise ConfigEntryNotReady( - f"Could not setup camera {device.device.host}:{device.device.port}: {err}" + f"Could not setup camera {device.device.host}:{device.device.port}: {stringify_onvif_error(err)}" + ) from err + except TransportError as err: + await device.device.close() + stringified_onvif_error = stringify_onvif_error(err) + if err.status_code in ( + HTTPStatus.UNAUTHORIZED.value, + HTTPStatus.FORBIDDEN.value, + ): + raise ConfigEntryAuthFailed( + f"Auth Failed: {stringified_onvif_error}" + ) from err + raise ConfigEntryNotReady( + f"Could not setup camera {device.device.host}:{device.device.port}: {stringified_onvif_error}" ) from err except asyncio.CancelledError as err: # After https://github.com/agronholm/anyio/issues/374 is resolved diff --git a/homeassistant/components/onvif/config_flow.py b/homeassistant/components/onvif/config_flow.py index 27f279266ddb..ca447c71b844 100644 --- a/homeassistant/components/onvif/config_flow.py +++ b/homeassistant/components/onvif/config_flow.py @@ -142,10 +142,14 @@ class OnvifFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): hass.async_create_task(hass.config_entries.async_reload(entry_id)) return self.async_abort(reason="reauth_successful") + username = (user_input or {}).get(CONF_USERNAME) or entry.data[CONF_USERNAME] return self.async_show_form( step_id="reauth_confirm", data_schema=vol.Schema( - {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} + { + vol.Required(CONF_USERNAME, default=username): str, + vol.Required(CONF_PASSWORD): str, + } ), errors=errors, description_placeholders=description_placeholders, diff --git a/homeassistant/components/onvif/strings.json b/homeassistant/components/onvif/strings.json index 55413e4bf6cd..3e9db0b3c7e6 100644 --- a/homeassistant/components/onvif/strings.json +++ b/homeassistant/components/onvif/strings.json @@ -47,6 +47,7 @@ }, "reauth_confirm": { "title": "Reauthenticate the ONVIF device", + "description": "Some devices will reject authentication if the time is out of sync by more than 5 seconds. If authentication is unsuccessful, verify the time on the device is correct and try again.", "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" diff --git a/tests/components/onvif/test_config_flow.py b/tests/components/onvif/test_config_flow.py index 21ef1cf3fc24..8187a427be9e 100644 --- a/tests/components/onvif/test_config_flow.py +++ b/tests/components/onvif/test_config_flow.py @@ -5,7 +5,7 @@ from homeassistant import config_entries, data_entry_flow from homeassistant.components import dhcp from homeassistant.components.onvif import DOMAIN, config_flow from homeassistant.config_entries import SOURCE_DHCP -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_HOST, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr @@ -710,6 +710,14 @@ async def test_discovered_by_dhcp_does_not_update_if_no_matching_entry( assert result["reason"] == "no_devices_found" +def _get_schema_default(schema, key_name): + """Iterate schema to find a key.""" + for schema_key in schema: + if schema_key == key_name: + return schema_key.default() + raise KeyError(f"{key_name} not found in schema") + + async def test_form_reauth(hass: HomeAssistant) -> None: """Test reauthenticate.""" entry, _, _ = await setup_onvif_integration(hass) @@ -721,6 +729,10 @@ async def test_form_reauth(hass: HomeAssistant) -> None: ) assert result["type"] == FlowResultType.FORM assert result["step_id"] == "reauth_confirm" + assert ( + _get_schema_default(result["data_schema"].schema, CONF_USERNAME) + == entry.data[CONF_USERNAME] + ) with patch( "homeassistant.components.onvif.config_flow.get_device" From 99ab7c5a374fe7bf4a82c84d5cedefc8b212b74a Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Sun, 7 May 2023 00:36:21 +0200 Subject: [PATCH 246/822] Bump pyoverkiz to 1.7.8 (#92702) --- 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 caa4f6c3868f..dfd4a6c28e14 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.7.7"], + "requirements": ["pyoverkiz==1.7.8"], "zeroconf": [ { "type": "_kizbox._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index f4135294b534..b3888698320e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1856,7 +1856,7 @@ pyotgw==2.1.3 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.7.7 +pyoverkiz==1.7.8 # homeassistant.components.openweathermap pyowm==3.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f5bae995eda1..85cccb4e391c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1360,7 +1360,7 @@ pyotgw==2.1.3 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.7.7 +pyoverkiz==1.7.8 # homeassistant.components.openweathermap pyowm==3.2.0 From 4a2af45e374ab60fe3927959125712ec8e09a6c4 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Sun, 7 May 2023 00:44:26 +0200 Subject: [PATCH 247/822] Change IO_OUTLET_ENGINE sensor from Liter to Cubic Meters Per Hour in Overkiz (#92704) --- homeassistant/components/overkiz/sensor.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/overkiz/sensor.py b/homeassistant/components/overkiz/sensor.py index 4c70bab70f5b..9aca0850b053 100644 --- a/homeassistant/components/overkiz/sensor.py +++ b/homeassistant/components/overkiz/sensor.py @@ -116,8 +116,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ key=OverkizState.IO_OUTLET_ENGINE, name="Outlet engine", icon="mdi:fan-chevron-down", - native_unit_of_measurement=UnitOfVolume.LITERS, - device_class=SensorDeviceClass.VOLUME, + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, state_class=SensorStateClass.MEASUREMENT, ), OverkizSensorDescription( From c624e50b60e0f08faf42660390e7c707233d295c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 6 May 2023 17:47:15 -0500 Subject: [PATCH 248/822] Update onvif error checking to use explict None check (#92642) --- homeassistant/components/onvif/util.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/onvif/util.py b/homeassistant/components/onvif/util.py index a88a37f5d205..5077a65e0b0c 100644 --- a/homeassistant/components/onvif/util.py +++ b/homeassistant/components/onvif/util.py @@ -17,16 +17,16 @@ def stringify_onvif_error(error: Exception) -> str: """Stringify ONVIF error.""" if isinstance(error, Fault): message = error.message - if error.detail: + if error.detail is not None: # checking true is deprecated # Detail may be a bytes object, so we need to convert it to string if isinstance(error.detail, bytes): detail = error.detail.decode("utf-8", "replace") else: detail = str(error.detail) message += ": " + detail - if error.code: + if error.code is not None: # checking true is deprecated message += f" (code:{error.code})" - if error.subcodes: + if error.subcodes is not None: # checking true is deprecated message += ( f" (subcodes:{','.join(extract_subcodes_as_strings(error.subcodes))})" ) From 7e19bb4ee06652de84746ffdf13cb8f2ba51c44f Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 7 May 2023 00:55:06 +0200 Subject: [PATCH 249/822] Minor cleanup config flow Workday (#92163) --- .../components/workday/config_flow.py | 26 ++++++++----------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/workday/config_flow.py b/homeassistant/components/workday/config_flow.py index be11b0b034d8..a66d965a3513 100644 --- a/homeassistant/components/workday/config_flow.py +++ b/homeassistant/components/workday/config_flow.py @@ -3,8 +3,7 @@ from __future__ import annotations from typing import Any -import holidays -from holidays import HolidayBase +from holidays import country_holidays, list_supported_countries import voluptuous as vol from homeassistant.config_entries import ( @@ -48,15 +47,14 @@ NONE_SENTINEL = "none" def add_province_to_schema( schema: vol.Schema, - options: dict[str, Any], + country: str, ) -> vol.Schema: """Update schema with province from country.""" - year: int = dt.now().year - obj_holidays: HolidayBase = getattr(holidays, options[CONF_COUNTRY])(years=year) - if not obj_holidays.subdivisions: + all_countries = list_supported_countries() + if not all_countries[country]: return schema - province_list = [NONE_SENTINEL, *obj_holidays.subdivisions] + province_list = [NONE_SENTINEL, *all_countries[country]] add_schema = { vol.Optional(CONF_PROVINCE, default=NONE_SENTINEL): SelectSelector( SelectSelectorConfig( @@ -78,11 +76,9 @@ def validate_custom_dates(user_input: dict[str, Any]) -> None: raise AddDatesError("Incorrect date") year: int = dt.now().year - obj_holidays: HolidayBase = getattr(holidays, user_input[CONF_COUNTRY])(years=year) - if user_input.get(CONF_PROVINCE): - obj_holidays = getattr(holidays, user_input[CONF_COUNTRY])( - subdiv=user_input[CONF_PROVINCE], years=year - ) + obj_holidays = country_holidays( + user_input[CONF_COUNTRY], user_input.get(CONF_PROVINCE), year + ) for remove_date in user_input[CONF_REMOVE_HOLIDAYS]: if dt.parse_date(remove_date) is None: @@ -95,7 +91,7 @@ DATA_SCHEMA_SETUP = vol.Schema( vol.Required(CONF_NAME, default=DEFAULT_NAME): TextSelector(), vol.Required(CONF_COUNTRY): SelectSelector( SelectSelectorConfig( - options=list(holidays.list_supported_countries()), + options=list(list_supported_countries()), mode=SelectSelectorMode.DROPDOWN, ) ), @@ -231,7 +227,7 @@ class WorkdayConfigFlow(ConfigFlow, domain=DOMAIN): ) schema = await self.hass.async_add_executor_job( - add_province_to_schema, DATA_SCHEMA_OPT, self.data + add_province_to_schema, DATA_SCHEMA_OPT, self.data[CONF_COUNTRY] ) new_schema = self.add_suggested_values_to_schema(schema, user_input) return self.async_show_form( @@ -282,7 +278,7 @@ class WorkdayOptionsFlowHandler(OptionsFlowWithConfigEntry): return self.async_create_entry(data=combined_input) schema: vol.Schema = await self.hass.async_add_executor_job( - add_province_to_schema, DATA_SCHEMA_OPT, self.options + add_province_to_schema, DATA_SCHEMA_OPT, self.options[CONF_COUNTRY] ) new_schema = self.add_suggested_values_to_schema( From 7cb25e971094da7872e77cd2068b0c9cac38f40a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 7 May 2023 01:28:10 +0200 Subject: [PATCH 250/822] Fix lingering timer in generic_hygrostat (#92575) --- .../components/generic_hygrostat/humidifier.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/generic_hygrostat/humidifier.py b/homeassistant/components/generic_hygrostat/humidifier.py index c1ebc948b94d..a6e76330f29b 100644 --- a/homeassistant/components/generic_hygrostat/humidifier.py +++ b/homeassistant/components/generic_hygrostat/humidifier.py @@ -172,7 +172,11 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): ) if self._keep_alive: - async_track_time_interval(self.hass, self._async_operate, self._keep_alive) + self.async_on_remove( + async_track_time_interval( + self.hass, self._async_operate, self._keep_alive + ) + ) async def _async_startup(event): """Init on startup.""" @@ -216,6 +220,12 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): await _async_startup(None) # init the sensor + async def async_will_remove_from_hass(self) -> None: + """Run when entity will be removed from hass.""" + if self._remove_stale_tracking: + self._remove_stale_tracking() + return await super().async_will_remove_from_hass() + @property def available(self): """Return True if entity is available.""" From ce3cb5864a2e983af52d1995eae99fdf98909d1d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 6 May 2023 18:28:43 -0500 Subject: [PATCH 251/822] Improve reliablity of recorder purge test (#92641) --- tests/components/recorder/test_purge.py | 34 ++++--------------------- 1 file changed, 5 insertions(+), 29 deletions(-) diff --git a/tests/components/recorder/test_purge.py b/tests/components/recorder/test_purge.py index 04635acbcabd..61f2d57a081c 100644 --- a/tests/components/recorder/test_purge.py +++ b/tests/components/recorder/test_purge.py @@ -38,7 +38,6 @@ from homeassistant.const import EVENT_STATE_CHANGED, EVENT_THEMES_UPDATED, STATE from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util -from homeassistant.util.json import json_loads from .common import ( async_recorder_block_till_done, @@ -1452,6 +1451,11 @@ async def _add_test_events(hass: HomeAssistant, iterations: int = 1): five_days_ago = utcnow - timedelta(days=5) eleven_days_ago = utcnow - timedelta(days=11) event_data = {"test_attr": 5, "test_attr_10": "nice"} + # Make sure recording is done before freezing time + # because the time freeze can affect the recorder + # thread as well can cause the test to fail + await async_wait_recording_done(hass) + for _ in range(iterations): for event_id in range(6): if event_id < 2: @@ -1469,34 +1473,6 @@ async def _add_test_events(hass: HomeAssistant, iterations: int = 1): await async_wait_recording_done(hass) -async def _add_events_with_event_data(hass: HomeAssistant, iterations: int = 1): - """Add a few events with linked event_data for testing.""" - utcnow = dt_util.utcnow() - five_days_ago = utcnow - timedelta(days=5) - eleven_days_ago = utcnow - timedelta(days=11) - - await hass.async_block_till_done() - for _ in range(iterations): - for event_id in range(6): - if event_id < 2: - timestamp = eleven_days_ago - event_type = "EVENT_TEST_AUTOPURGE_WITH_EVENT_DATA" - shared_data = '{"type":{"EVENT_TEST_AUTOPURGE_WITH_EVENT_DATA"}' - elif event_id < 4: - timestamp = five_days_ago - event_type = "EVENT_TEST_PURGE_WITH_EVENT_DATA" - shared_data = '{"type":{"EVENT_TEST_PURGE_WITH_EVENT_DATA"}' - else: - timestamp = utcnow - event_type = "EVENT_TEST_WITH_EVENT_DATA" - shared_data = '{"type":{"EVENT_TEST_WITH_EVENT_DATA"}' - - with freeze_time(timestamp): - hass.bus.async_fire(event_type, json_loads(shared_data)) - - await async_wait_recording_done(hass) - - async def _add_test_statistics(hass: HomeAssistant): """Add multiple statistics to the db for testing.""" utcnow = dt_util.utcnow() From f47f49c9a1c113542b0b815869928db9dddc6ee5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 6 May 2023 18:30:54 -0500 Subject: [PATCH 252/822] Reduce overhead to check bluetooth logging (#92512) --- homeassistant/components/bluetooth/manager.py | 26 ++++++--- .../components/bluetooth/manifest.json | 2 +- homeassistant/components/logger/__init__.py | 1 + tests/components/bluetooth/test_manager.py | 57 +++++++++++++++++++ tests/components/bluetooth/test_scanner.py | 8 ++- 5 files changed, 85 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py index 75809f42e6cb..3210822e7952 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -18,6 +18,7 @@ from bluetooth_adapters import ( ) from homeassistant import config_entries +from homeassistant.components.logger import EVENT_LOGGING_CHANGED from homeassistant.core import ( CALLBACK_TYPE, Event, @@ -113,6 +114,7 @@ class BluetoothManager: self.hass = hass self._integration_matcher = integration_matcher self._cancel_unavailable_tracking: CALLBACK_TYPE | None = None + self._cancel_logging_listener: CALLBACK_TYPE | None = None self._advertisement_tracker = AdvertisementTracker() @@ -136,6 +138,7 @@ class BluetoothManager: self._bluetooth_adapters = bluetooth_adapters self.storage = storage self.slot_manager = slot_manager + self._debug = _LOGGER.isEnabledFor(logging.DEBUG) @property def supports_passive_scan(self) -> bool: @@ -201,6 +204,11 @@ class BluetoothManager: self._adapters = self._bluetooth_adapters.adapters return self._find_adapter_by_address(address) + @hass_callback + def _async_logging_changed(self, event: Event) -> None: + """Handle logging change.""" + self._debug = _LOGGER.isEnabledFor(logging.DEBUG) + async def async_setup(self) -> None: """Set up the bluetooth manager.""" await self._bluetooth_adapters.refresh() @@ -208,6 +216,9 @@ class BluetoothManager: self._all_history, self._connectable_history = async_load_history_from_system( self._bluetooth_adapters, self.storage ) + self._cancel_logging_listener = self.hass.bus.async_listen( + EVENT_LOGGING_CHANGED, self._async_logging_changed + ) self.async_setup_unavailable_tracking() seen: set[str] = set() for address, service_info in itertools.chain( @@ -225,6 +236,9 @@ class BluetoothManager: if self._cancel_unavailable_tracking: self._cancel_unavailable_tracking() self._cancel_unavailable_tracking = None + if self._cancel_logging_listener: + self._cancel_logging_listener() + self._cancel_logging_listener = None uninstall_multiple_bleak_catcher() @hass_callback @@ -342,7 +356,6 @@ class BluetoothManager: self, old: BluetoothServiceInfoBleak, new: BluetoothServiceInfoBleak, - debug: bool, ) -> bool: """Prefer previous advertisement from a different source if it is better.""" if new.time - old.time > ( @@ -351,7 +364,7 @@ class BluetoothManager: ) ): # If the old advertisement is stale, any new advertisement is preferred - if debug: + if self._debug: _LOGGER.debug( ( "%s (%s): Switching from %s to %s (time elapsed:%s > stale" @@ -370,7 +383,7 @@ class BluetoothManager: ): # If new advertisement is RSSI_SWITCH_THRESHOLD more, # the new one is preferred. - if debug: + if self._debug: _LOGGER.debug( ( "%s (%s): Switching from %s to %s (new rssi:%s - threshold:%s >" @@ -414,7 +427,6 @@ class BluetoothManager: old_connectable_service_info = connectable and connectable_history.get(address) source = service_info.source - debug = _LOGGER.isEnabledFor(logging.DEBUG) # This logic is complex due to the many combinations of scanners # that are supported. # @@ -437,7 +449,7 @@ class BluetoothManager: and (scanner := self._sources.get(old_service_info.source)) and scanner.scanning and self._prefer_previous_adv_from_different_source( - old_service_info, service_info, debug + old_service_info, service_info ) ): # If we are rejecting the new advertisement and the device is connectable @@ -461,7 +473,7 @@ class BluetoothManager: ) and connectable_scanner.scanning and self._prefer_previous_adv_from_different_source( - old_connectable_service_info, service_info, debug + old_connectable_service_info, service_info ) ) ): @@ -523,7 +535,7 @@ class BluetoothManager: ) matched_domains = self._integration_matcher.match_domains(service_info) - if debug: + if self._debug: _LOGGER.debug( "%s: %s %s match: %s", self._async_describe_source(service_info), diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 5f535819473a..dd384e80c1ed 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -4,7 +4,7 @@ "after_dependencies": ["hassio"], "codeowners": ["@bdraco"], "config_flow": true, - "dependencies": ["usb"], + "dependencies": ["logger", "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 0d087ef23b72..fe29447aeba4 100644 --- a/homeassistant/components/logger/__init__.py +++ b/homeassistant/components/logger/__init__.py @@ -22,6 +22,7 @@ from .const import ( SERVICE_SET_DEFAULT_LEVEL, SERVICE_SET_LEVEL, ) +from .const import EVENT_LOGGING_CHANGED # noqa: F401 from .helpers import ( LoggerDomainConfig, LoggerSettings, diff --git a/tests/components/bluetooth/test_manager.py b/tests/components/bluetooth/test_manager.py index bd1aaea5b6f9..85da27b027e1 100644 --- a/tests/components/bluetooth/test_manager.py +++ b/tests/components/bluetooth/test_manager.py @@ -992,3 +992,60 @@ async def test_goes_unavailable_dismisses_discovery_and_makes_discoverable( cancel() unsetup_connectable_scanner() cancel_connectable_scanner() + + +async def test_debug_logging( + hass: HomeAssistant, + enable_bluetooth: None, + register_hci0_scanner: None, + register_hci1_scanner: None, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test debug logging.""" + await hass.services.async_call( + "logger", + "set_level", + {"homeassistant.components.bluetooth": "DEBUG"}, + blocking=True, + ) + + address = "44:44:33:11:23:41" + start_time_monotonic = 50.0 + + switchbot_device_poor_signal_hci0 = generate_ble_device( + address, "wohand_poor_signal_hci0" + ) + switchbot_adv_poor_signal_hci0 = generate_advertisement_data( + local_name="wohand_poor_signal_hci0", service_uuids=[], rssi=-100 + ) + inject_advertisement_with_time_and_source( + hass, + switchbot_device_poor_signal_hci0, + switchbot_adv_poor_signal_hci0, + start_time_monotonic, + "hci0", + ) + assert "wohand_poor_signal_hci0" in caplog.text + caplog.clear() + + await hass.services.async_call( + "logger", + "set_level", + {"homeassistant.components.bluetooth": "WARNING"}, + blocking=True, + ) + + switchbot_device_good_signal_hci0 = generate_ble_device( + address, "wohand_good_signal_hci0" + ) + switchbot_adv_good_signal_hci0 = generate_advertisement_data( + local_name="wohand_good_signal_hci0", service_uuids=[], rssi=-33 + ) + inject_advertisement_with_time_and_source( + hass, + switchbot_device_good_signal_hci0, + switchbot_adv_good_signal_hci0, + start_time_monotonic, + "hci0", + ) + assert "wohand_good_signal_hci0" not in caplog.text diff --git a/tests/components/bluetooth/test_scanner.py b/tests/components/bluetooth/test_scanner.py index fcff8c15d58f..bc32a5b302d4 100644 --- a/tests/components/bluetooth/test_scanner.py +++ b/tests/components/bluetooth/test_scanner.py @@ -461,7 +461,13 @@ async def test_adapter_fails_to_start_and_takes_a_bit_to_init( hass: HomeAssistant, one_adapter: None, caplog: pytest.LogCaptureFixture ) -> None: """Test we can recover the adapter at startup and we wait for Dbus to init.""" - + assert await async_setup_component(hass, "logger", {}) + await hass.services.async_call( + "logger", + "set_level", + {"homeassistant.components.bluetooth": "DEBUG"}, + blocking=True, + ) called_start = 0 called_stop = 0 _callback = None From 2c5cad4ca0d64c7396794b83cc5284a7baec6e90 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 7 May 2023 01:42:37 +0200 Subject: [PATCH 253/822] Clean config flow for SQL (#92332) --- .strict-typing | 1 + homeassistant/components/sql/config_flow.py | 48 ++++++++++++--------- mypy.ini | 10 +++++ tests/components/sql/__init__.py | 8 ++++ tests/components/sql/test_config_flow.py | 47 +++++++++++++++----- 5 files changed, 82 insertions(+), 32 deletions(-) diff --git a/.strict-typing b/.strict-typing index caab7641a89e..f7297d8e6808 100644 --- a/.strict-typing +++ b/.strict-typing @@ -286,6 +286,7 @@ homeassistant.components.smhi.* homeassistant.components.snooz.* homeassistant.components.sonarr.* homeassistant.components.speedtestdotnet.* +homeassistant.components.sql.* homeassistant.components.ssdp.* homeassistant.components.statistics.* homeassistant.components.steamist.* diff --git a/homeassistant/components/sql/config_flow.py b/homeassistant/components/sql/config_flow.py index 7cbcbe73efaf..a6c526a6a7f4 100644 --- a/homeassistant/components/sql/config_flow.py +++ b/homeassistant/components/sql/config_flow.py @@ -114,9 +114,6 @@ class SQLConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): db_url = user_input.get(CONF_DB_URL) query = user_input[CONF_QUERY] column = user_input[CONF_COLUMN_NAME] - uom = user_input.get(CONF_UNIT_OF_MEASUREMENT) - value_template = user_input.get(CONF_VALUE_TEMPLATE) - name = user_input[CONF_NAME] db_url_for_validation = None try: @@ -133,22 +130,23 @@ class SQLConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): except ValueError: errors["query"] = "query_invalid" - add_db_url = ( - {CONF_DB_URL: db_url} if db_url == db_url_for_validation else {} - ) + options = { + CONF_QUERY: query, + CONF_COLUMN_NAME: column, + CONF_NAME: user_input[CONF_NAME], + } + if uom := user_input.get(CONF_UNIT_OF_MEASUREMENT): + options[CONF_UNIT_OF_MEASUREMENT] = uom + if value_template := user_input.get(CONF_VALUE_TEMPLATE): + options[CONF_VALUE_TEMPLATE] = value_template + if db_url_for_validation != get_instance(self.hass).db_url: + options[CONF_DB_URL] = db_url_for_validation if not errors: return self.async_create_entry( - title=name, + title=user_input[CONF_NAME], data={}, - options={ - **add_db_url, - CONF_QUERY: query, - CONF_COLUMN_NAME: column, - CONF_UNIT_OF_MEASUREMENT: uom, - CONF_VALUE_TEMPLATE: value_template, - CONF_NAME: name, - }, + options=options, ) return self.async_show_form( @@ -196,13 +194,21 @@ class SQLOptionsFlowHandler(config_entries.OptionsFlowWithConfigEntry): db_url_for_validation, recorder_db, ) - if db_url and db_url_for_validation == recorder_db: - user_input.pop(CONF_DB_URL) + + options = { + CONF_QUERY: query, + CONF_COLUMN_NAME: column, + CONF_NAME: name, + } + if uom := user_input.get(CONF_UNIT_OF_MEASUREMENT): + options[CONF_UNIT_OF_MEASUREMENT] = uom + if value_template := user_input.get(CONF_VALUE_TEMPLATE): + options[CONF_VALUE_TEMPLATE] = value_template + if db_url_for_validation != get_instance(self.hass).db_url: + options[CONF_DB_URL] = db_url_for_validation + return self.async_create_entry( - data={ - CONF_NAME: name, - **user_input, - }, + data=options, ) return self.async_show_form( diff --git a/mypy.ini b/mypy.ini index 7f8ae23d38d2..b4155232ff12 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2622,6 +2622,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.sql.*] +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.ssdp.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/tests/components/sql/__init__.py b/tests/components/sql/__init__.py index 5a941b37d631..9927a9734cd6 100644 --- a/tests/components/sql/__init__.py +++ b/tests/components/sql/__init__.py @@ -29,6 +29,14 @@ ENTRY_CONFIG = { CONF_UNIT_OF_MEASUREMENT: "MiB", } +ENTRY_CONFIG_WITH_VALUE_TEMPLATE = { + CONF_NAME: "Get Value", + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_VALUE_TEMPLATE: "{{ value }}", +} + ENTRY_CONFIG_INVALID_QUERY = { CONF_NAME: "Get Value", CONF_QUERY: "UPDATE 5 as value", diff --git a/tests/components/sql/test_config_flow.py b/tests/components/sql/test_config_flow.py index a8e590a9760f..8958454ac62c 100644 --- a/tests/components/sql/test_config_flow.py +++ b/tests/components/sql/test_config_flow.py @@ -18,6 +18,7 @@ from . import ( ENTRY_CONFIG_INVALID_QUERY, ENTRY_CONFIG_INVALID_QUERY_OPT, ENTRY_CONFIG_NO_RESULTS, + ENTRY_CONFIG_WITH_VALUE_TEMPLATE, ) from tests.common import MockConfigEntry @@ -49,7 +50,39 @@ async def test_form(recorder_mock: Recorder, hass: HomeAssistant) -> None: "query": "SELECT 5 as value", "column": "value", "unit_of_measurement": "MiB", - "value_template": None, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_with_value_template( + recorder_mock: Recorder, hass: HomeAssistant +) -> None: + """Test for with value template.""" + + 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.sql.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + ENTRY_CONFIG_WITH_VALUE_TEMPLATE, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "Get Value" + assert result2["options"] == { + "name": "Get Value", + "query": "SELECT 5 as value", + "column": "value", + "unit_of_measurement": "MiB", + "value_template": "{{ value }}", } assert len(mock_setup_entry.mock_calls) == 1 @@ -118,7 +151,6 @@ async def test_flow_fails_invalid_query( "query": "SELECT 5 as value", "column": "value", "unit_of_measurement": "MiB", - "value_template": None, } @@ -155,7 +187,6 @@ async def test_flow_fails_invalid_column_name( "query": "SELECT 5 as value", "column": "value", "unit_of_measurement": "MiB", - "value_template": None, } @@ -170,7 +201,6 @@ async def test_options_flow(recorder_mock: Recorder, hass: HomeAssistant) -> Non "query": "SELECT 5 as value", "column": "value", "unit_of_measurement": "MiB", - "value_template": None, }, ) entry.add_to_hass(hass) @@ -194,6 +224,7 @@ async def test_options_flow(recorder_mock: Recorder, hass: HomeAssistant) -> Non "query": "SELECT 5 as size", "column": "size", "unit_of_measurement": "MiB", + "value_template": "{{ value }}", }, ) @@ -203,6 +234,7 @@ async def test_options_flow(recorder_mock: Recorder, hass: HomeAssistant) -> Non "query": "SELECT 5 as size", "column": "size", "unit_of_measurement": "MiB", + "value_template": "{{ value }}", } @@ -218,7 +250,6 @@ async def test_options_flow_name_previously_removed( "query": "SELECT 5 as value", "column": "value", "unit_of_measurement": "MiB", - "value_template": None, }, title="Get Value Title", ) @@ -270,7 +301,6 @@ async def test_options_flow_fails_db_url( "query": "SELECT 5 as value", "column": "value", "unit_of_measurement": "MiB", - "value_template": None, }, ) entry.add_to_hass(hass) @@ -314,7 +344,6 @@ async def test_options_flow_fails_invalid_query( "query": "SELECT 5 as value", "column": "value", "unit_of_measurement": "MiB", - "value_template": None, }, ) entry.add_to_hass(hass) @@ -369,7 +398,6 @@ async def test_options_flow_fails_invalid_column_name( "query": "SELECT 5 as value", "column": "value", "unit_of_measurement": "MiB", - "value_template": None, }, ) entry.add_to_hass(hass) @@ -424,7 +452,6 @@ async def test_options_flow_db_url_empty( "query": "SELECT 5 as value", "column": "value", "unit_of_measurement": "MiB", - "value_template": None, }, ) entry.add_to_hass(hass) @@ -500,8 +527,6 @@ async def test_full_flow_not_recorder_db( "db_url": "sqlite://path/to/db.db", "query": "SELECT 5 as value", "column": "value", - "unit_of_measurement": None, - "value_template": None, } entry = hass.config_entries.async_entries(DOMAIN)[0] From b22c45ea29806853ed7f6aacb41148436c559df1 Mon Sep 17 00:00:00 2001 From: Thijs W Date: Sun, 7 May 2023 08:59:36 +0200 Subject: [PATCH 254/822] Fix radio_id can be unavailable (#92698) Allow radio_id to be unavailable --- .../frontier_silicon/config_flow.py | 34 ++++-- .../frontier_silicon/test_config_flow.py | 111 +++++++++++++----- 2 files changed, 105 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/frontier_silicon/config_flow.py b/homeassistant/components/frontier_silicon/config_flow.py index 7067f8829736..5179b02bbc3c 100644 --- a/homeassistant/components/frontier_silicon/config_flow.py +++ b/homeassistant/components/frontier_silicon/config_flow.py @@ -6,7 +6,12 @@ import logging from typing import Any from urllib.parse import urlparse -from afsapi import AFSAPI, ConnectionError as FSConnectionError, InvalidPinException +from afsapi import ( + AFSAPI, + ConnectionError as FSConnectionError, + InvalidPinException, + NotImplementedException, +) import voluptuous as vol from homeassistant import config_entries @@ -68,10 +73,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): _LOGGER.exception(exception) return self.async_abort(reason="unknown") + afsapi = AFSAPI(webfsapi_url, import_info[CONF_PIN]) try: - afsapi = AFSAPI(webfsapi_url, import_info[CONF_PIN]) - unique_id = await afsapi.get_radio_id() + except NotImplementedException: + unique_id = None # Not all radios have this call implemented except FSConnectionError: return self.async_abort(reason="cannot_connect") except InvalidPinException: @@ -141,14 +147,18 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): _LOGGER.debug(exception) return self.async_abort(reason="unknown") + # try to login with default pin + afsapi = AFSAPI(self._webfsapi_url, DEFAULT_PIN) try: - # try to login with default pin - afsapi = AFSAPI(self._webfsapi_url, DEFAULT_PIN) - - unique_id = await afsapi.get_radio_id() + await afsapi.get_friendly_name() except InvalidPinException: return self.async_abort(reason="invalid_auth") + try: + unique_id = await afsapi.get_radio_id() + except NotImplementedException: + unique_id = None + await self.async_set_unique_id(unique_id) self._abort_if_unique_id_configured( updates={CONF_WEBFSAPI_URL: self._webfsapi_url}, reload_on_update=True @@ -175,7 +185,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self.context["title_placeholders"] = {"name": self._name} - unique_id = await afsapi.get_radio_id() + try: + unique_id = await afsapi.get_radio_id() + except NotImplementedException: + unique_id = None await self.async_set_unique_id(unique_id) self._abort_if_unique_id_configured() @@ -240,7 +253,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): await self.hass.config_entries.async_reload(self._reauth_entry.entry_id) return self.async_abort(reason="reauth_successful") - unique_id = await afsapi.get_radio_id() + try: + unique_id = await afsapi.get_radio_id() + except NotImplementedException: + unique_id = None await self.async_set_unique_id(unique_id, raise_on_progress=False) self._abort_if_unique_id_configured() return await self._async_create_entry(user_input[CONF_PIN]) diff --git a/tests/components/frontier_silicon/test_config_flow.py b/tests/components/frontier_silicon/test_config_flow.py index 524b985b125d..c7ff99d877c0 100644 --- a/tests/components/frontier_silicon/test_config_flow.py +++ b/tests/components/frontier_silicon/test_config_flow.py @@ -1,7 +1,7 @@ """Test the Frontier Silicon config flow.""" from unittest.mock import AsyncMock, patch -from afsapi import ConnectionError, InvalidPinException +from afsapi import ConnectionError, InvalidPinException, NotImplementedException import pytest from homeassistant import config_entries @@ -37,19 +37,31 @@ INVALID_MOCK_DISCOVERY = ssdp.SsdpServiceInfo( ) -async def test_import_success(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("radio_id_return_value", "radio_id_side_effect"), + [("mock_radio_id", None), (None, NotImplementedException)], +) +async def test_import_success( + hass: HomeAssistant, + radio_id_return_value: str | None, + radio_id_side_effect: Exception | None, +) -> None: """Test successful import.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_HOST: "1.1.1.1", - CONF_PORT: 80, - CONF_PIN: "1234", - CONF_NAME: "Test name", - }, - ) + with patch( + "homeassistant.components.frontier_silicon.config_flow.AFSAPI.get_radio_id", + return_value=radio_id_return_value, + side_effect=radio_id_side_effect, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_HOST: "1.1.1.1", + CONF_PORT: 80, + CONF_PIN: "1234", + CONF_NAME: "Test name", + }, + ) assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == "Test name" @@ -141,8 +153,15 @@ async def test_import_already_exists( assert result["reason"] == "already_configured" +@pytest.mark.parametrize( + ("radio_id_return_value", "radio_id_side_effect"), + [("mock_radio_id", None), (None, NotImplementedException)], +) async def test_form_default_pin( - hass: HomeAssistant, mock_setup_entry: AsyncMock + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + radio_id_return_value: str | None, + radio_id_side_effect: Exception | None, ) -> None: """Test manual device add with default pin.""" result = await hass.config_entries.flow.async_init( @@ -152,10 +171,15 @@ async def test_form_default_pin( assert result["step_id"] == "user" assert result["errors"] == {} - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_HOST: "1.1.1.1", CONF_PORT: 80}, - ) + with patch( + "homeassistant.components.frontier_silicon.config_flow.AFSAPI.get_radio_id", + return_value=radio_id_return_value, + side_effect=radio_id_side_effect, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.1.1.1", CONF_PORT: 80}, + ) await hass.async_block_till_done() assert result2["type"] == FlowResultType.CREATE_ENTRY @@ -167,8 +191,15 @@ async def test_form_default_pin( mock_setup_entry.assert_called_once() +@pytest.mark.parametrize( + ("radio_id_return_value", "radio_id_side_effect"), + [("mock_radio_id", None), (None, NotImplementedException)], +) async def test_form_nondefault_pin( - hass: HomeAssistant, mock_setup_entry: AsyncMock + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + radio_id_return_value: str | None, + radio_id_side_effect: Exception | None, ) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -192,10 +223,15 @@ async def test_form_nondefault_pin( assert result2["step_id"] == "device_config" assert result2["errors"] is None - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - {CONF_PIN: "4321"}, - ) + with patch( + "homeassistant.components.frontier_silicon.config_flow.AFSAPI.get_radio_id", + return_value=radio_id_return_value, + side_effect=radio_id_side_effect, + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_PIN: "4321"}, + ) await hass.async_block_till_done() assert result3["type"] == FlowResultType.CREATE_ENTRY @@ -322,14 +358,27 @@ async def test_invalid_device_url( mock_setup_entry.assert_called_once() -async def test_ssdp(hass: HomeAssistant, mock_setup_entry: MockConfigEntry) -> None: +@pytest.mark.parametrize( + ("radio_id_return_value", "radio_id_side_effect"), + [("mock_radio_id", None), (None, NotImplementedException)], +) +async def test_ssdp( + hass: HomeAssistant, + mock_setup_entry: MockConfigEntry, + radio_id_return_value: str | None, + radio_id_side_effect: Exception | None, +) -> None: """Test a device being discovered.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=MOCK_DISCOVERY, - ) + with patch( + "homeassistant.components.frontier_silicon.config_flow.AFSAPI.get_radio_id", + return_value=radio_id_return_value, + side_effect=radio_id_side_effect, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=MOCK_DISCOVERY, + ) assert result["type"] == FlowResultType.FORM assert result["step_id"] == "confirm" @@ -404,7 +453,7 @@ async def test_ssdp_nondefault_pin(hass: HomeAssistant) -> None: """Test a device being discovered.""" with patch( - "homeassistant.components.frontier_silicon.config_flow.AFSAPI.get_radio_id", + "homeassistant.components.frontier_silicon.config_flow.AFSAPI.get_friendly_name", side_effect=InvalidPinException, ): result = await hass.config_entries.flow.async_init( From bf6d429339f0fd39d041b19eec2b5b54868ca0e5 Mon Sep 17 00:00:00 2001 From: andarotajo <55669170+andarotajo@users.noreply.github.com> Date: Sun, 7 May 2023 10:26:39 +0200 Subject: [PATCH 255/822] Add config flow to dwd_weather_warnings (#91040) * Add config flow to dwd_weather_warnings * Add additional test for more coverage * Apply code review changes * Apply further code review changes * Rename constant for configuration * Apply code review changes * Simplify config flow code --- CODEOWNERS | 1 + .../dwd_weather_warnings/__init__.py | 32 +++ .../dwd_weather_warnings/config_flow.py | 88 ++++++++ .../components/dwd_weather_warnings/const.py | 9 +- .../dwd_weather_warnings/manifest.json | 1 + .../components/dwd_weather_warnings/sensor.py | 65 ++++-- .../dwd_weather_warnings/strings.json | 25 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- requirements_test_all.txt | 3 + .../dwd_weather_warnings/__init__.py | 1 + .../dwd_weather_warnings/conftest.py | 16 ++ .../dwd_weather_warnings/test_config_flow.py | 200 ++++++++++++++++++ .../dwd_weather_warnings/test_init.py | 38 ++++ 14 files changed, 461 insertions(+), 21 deletions(-) create mode 100644 homeassistant/components/dwd_weather_warnings/config_flow.py create mode 100644 homeassistant/components/dwd_weather_warnings/strings.json create mode 100644 tests/components/dwd_weather_warnings/__init__.py create mode 100644 tests/components/dwd_weather_warnings/conftest.py create mode 100644 tests/components/dwd_weather_warnings/test_config_flow.py create mode 100644 tests/components/dwd_weather_warnings/test_init.py diff --git a/CODEOWNERS b/CODEOWNERS index d17dd12d448b..9468ef67619f 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -290,6 +290,7 @@ build.json @home-assistant/supervisor /homeassistant/components/dunehd/ @bieniu /tests/components/dunehd/ @bieniu /homeassistant/components/dwd_weather_warnings/ @runningman84 @stephan192 @Hummel95 @andarotajo +/tests/components/dwd_weather_warnings/ @runningman84 @stephan192 @Hummel95 @andarotajo /homeassistant/components/dynalite/ @ziv1234 /tests/components/dynalite/ @ziv1234 /homeassistant/components/eafm/ @Jc2k diff --git a/homeassistant/components/dwd_weather_warnings/__init__.py b/homeassistant/components/dwd_weather_warnings/__init__.py index 1841291f7a97..99c3a110caa8 100644 --- a/homeassistant/components/dwd_weather_warnings/__init__.py +++ b/homeassistant/components/dwd_weather_warnings/__init__.py @@ -1 +1,33 @@ """The dwd_weather_warnings component.""" + +from __future__ import annotations + +from dwdwfsapi import DwdWeatherWarningsAPI + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import CONF_REGION_IDENTIFIER, DOMAIN, PLATFORMS + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a config entry.""" + region_identifier: str = entry.data[CONF_REGION_IDENTIFIER] + + # Initialize the API. + api = await hass.async_add_executor_job(DwdWeatherWarningsAPI, region_identifier) + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = api + + 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/dwd_weather_warnings/config_flow.py b/homeassistant/components/dwd_weather_warnings/config_flow.py new file mode 100644 index 000000000000..653812632c0d --- /dev/null +++ b/homeassistant/components/dwd_weather_warnings/config_flow.py @@ -0,0 +1,88 @@ +"""Config flow for the dwd_weather_warnings integration.""" + +from __future__ import annotations + +from typing import Any, Final + +from dwdwfsapi import DwdWeatherWarningsAPI +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_NAME +from homeassistant.data_entry_flow import FlowResult +import homeassistant.helpers.config_validation as cv + +from .const import ( + CONF_REGION_IDENTIFIER, + CONF_REGION_NAME, + DEFAULT_NAME, + DOMAIN, + LOGGER, +) + +CONFIG_SCHEMA: Final = vol.Schema( + { + vol.Required(CONF_REGION_IDENTIFIER): cv.string, + } +) + + +class DwdWeatherWarningsConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle the config flow for the dwd_weather_warnings integration.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors: dict = {} + + if user_input is not None: + region_identifier = user_input[CONF_REGION_IDENTIFIER] + + # Validate region identifier using the API + if not await self.hass.async_add_executor_job( + DwdWeatherWarningsAPI, region_identifier + ): + errors["base"] = "invalid_identifier" + + if not errors: + # Set the unique ID for this config entry. + await self.async_set_unique_id(region_identifier) + self._abort_if_unique_id_configured() + + # Set the name for this config entry. + name = f"{DEFAULT_NAME} {region_identifier}" + + return self.async_create_entry(title=name, data=user_input) + + return self.async_show_form( + step_id="user", errors=errors, data_schema=CONFIG_SCHEMA + ) + + async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: + """Import a config entry from configuration.yaml.""" + LOGGER.debug( + "Starting import of sensor from configuration.yaml - %s", import_config + ) + + # Adjust data to new format. + region_identifier = import_config.pop(CONF_REGION_NAME) + import_config[CONF_REGION_IDENTIFIER] = region_identifier + + # Set the unique ID for this imported entry. + await self.async_set_unique_id(import_config[CONF_REGION_IDENTIFIER]) + self._abort_if_unique_id_configured() + + # Validate region identifier using the API + if not await self.hass.async_add_executor_job( + DwdWeatherWarningsAPI, region_identifier + ): + return self.async_abort(reason="invalid_identifier") + + name = import_config.get( + CONF_NAME, f"{DEFAULT_NAME} {import_config[CONF_REGION_IDENTIFIER]}" + ) + + return self.async_create_entry(title=name, data=import_config) diff --git a/homeassistant/components/dwd_weather_warnings/const.py b/homeassistant/components/dwd_weather_warnings/const.py index af8786f8d77d..75969dee119e 100644 --- a/homeassistant/components/dwd_weather_warnings/const.py +++ b/homeassistant/components/dwd_weather_warnings/const.py @@ -6,9 +6,14 @@ from datetime import timedelta import logging from typing import Final +from homeassistant.const import Platform + LOGGER = logging.getLogger(__package__) +DOMAIN: Final = "dwd_weather_warnings" + CONF_REGION_NAME: Final = "region_name" +CONF_REGION_IDENTIFIER: Final = "region_identifier" ATTR_REGION_NAME: Final = "region_name" ATTR_REGION_ID: Final = "region_id" @@ -29,5 +34,7 @@ API_ATTR_WARNING_COLOR: Final = "color" CURRENT_WARNING_SENSOR: Final = "current_warning_level" ADVANCE_WARNING_SENSOR: Final = "advance_warning_level" -DEFAULT_NAME: Final = "DWD-Weather-Warnings" +DEFAULT_NAME: Final = "DWD Weather Warnings" DEFAULT_SCAN_INTERVAL: Final = timedelta(minutes=15) + +PLATFORMS: Final[list[Platform]] = [Platform.SENSOR] diff --git a/homeassistant/components/dwd_weather_warnings/manifest.json b/homeassistant/components/dwd_weather_warnings/manifest.json index 2a22d5f8fb22..a383e33eab26 100644 --- a/homeassistant/components/dwd_weather_warnings/manifest.json +++ b/homeassistant/components/dwd_weather_warnings/manifest.json @@ -2,6 +2,7 @@ "domain": "dwd_weather_warnings", "name": "Deutscher Wetterdienst (DWD) Weather Warnings", "codeowners": ["@runningman84", "@stephan192", "@Hummel95", "@andarotajo"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/dwd_weather_warnings", "iot_class": "cloud_polling", "loggers": ["dwdwfsapi"], diff --git a/homeassistant/components/dwd_weather_warnings/sensor.py b/homeassistant/components/dwd_weather_warnings/sensor.py index 054d9e5ca8b7..de96b3e9e086 100644 --- a/homeassistant/components/dwd_weather_warnings/sensor.py +++ b/homeassistant/components/dwd_weather_warnings/sensor.py @@ -8,9 +8,11 @@ Unwetterwarnungen (Stufe 3) Warnungen vor markantem Wetter (Stufe 2) Wetterwarnungen (Stufe 1) """ + from __future__ import annotations -from dwdwfsapi import DwdWeatherWarningsAPI +from typing import Final + import voluptuous as vol from homeassistant.components.sensor import ( @@ -18,10 +20,12 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_MONITORED_CONDITIONS, CONF_NAME from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle @@ -43,8 +47,8 @@ from .const import ( ATTR_WARNING_COUNT, CONF_REGION_NAME, CURRENT_WARNING_SENSOR, - DEFAULT_NAME, DEFAULT_SCAN_INTERVAL, + DOMAIN, LOGGER, ) @@ -60,39 +64,59 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( icon="mdi:close-octagon-outline", ), ) -MONITORED_CONDITIONS: list[str] = [desc.key for desc in SENSOR_TYPES] +# Should be removed together with the old YAML configuration. +YAML_MONITORED_CONDITIONS: Final = [CURRENT_WARNING_SENSOR, ADVANCE_WARNING_SENSOR] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_REGION_NAME): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_NAME): cv.string, vol.Optional( - CONF_MONITORED_CONDITIONS, default=list(MONITORED_CONDITIONS) - ): vol.All(cv.ensure_list, [vol.In(MONITORED_CONDITIONS)]), + CONF_MONITORED_CONDITIONS, default=YAML_MONITORED_CONDITIONS + ): vol.All(cv.ensure_list, [vol.In(YAML_MONITORED_CONDITIONS)]), } ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the DWD-Weather-Warnings sensor.""" - name = config.get(CONF_NAME) - region_name = config.get(CONF_REGION_NAME) + """Import the configurations from YAML to config flows.""" + # Show issue as long as the YAML configuration exists. + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml", + breaks_in_ha_version="2023.8.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + ) - api = WrappedDwDWWAPI(DwdWeatherWarningsAPI(region_name)) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + ) - sensors = [ - DwdWeatherWarningsSensor(api, name, description) - for description in SENSOR_TYPES - if description.key in config[CONF_MONITORED_CONDITIONS] - ] - add_entities(sensors, True) +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up entities from config entry.""" + api = WrappedDwDWWAPI(hass.data[DOMAIN][entry.entry_id]) + + async_add_entities( + [ + DwdWeatherWarningsSensor(api, entry.title, entry.unique_id, description) + for description in SENSOR_TYPES + ], + True, + ) class DwdWeatherWarningsSensor(SensorEntity): @@ -104,23 +128,26 @@ class DwdWeatherWarningsSensor(SensorEntity): self, api, name, + unique_id, description: SensorEntityDescription, ) -> None: """Initialize a DWD-Weather-Warnings sensor.""" self._api = api self.entity_description = description self._attr_name = f"{name} {description.name}" + self._attr_unique_id = f"{unique_id}-{description.key}" @property def native_value(self): - """Return the state of the device.""" + """Return the state of the sensor.""" if self.entity_description.key == CURRENT_WARNING_SENSOR: return self._api.api.current_warning_level + return self._api.api.expected_warning_level @property def extra_state_attributes(self): - """Return the state attributes of the DWD-Weather-Warnings.""" + """Return the state attributes of the sensor.""" data = { ATTR_REGION_NAME: self._api.api.warncell_name, ATTR_REGION_ID: self._api.api.warncell_id, diff --git a/homeassistant/components/dwd_weather_warnings/strings.json b/homeassistant/components/dwd_weather_warnings/strings.json new file mode 100644 index 000000000000..c5c954a9f8e5 --- /dev/null +++ b/homeassistant/components/dwd_weather_warnings/strings.json @@ -0,0 +1,25 @@ +{ + "config": { + "step": { + "user": { + "description": "To identify the desired region, the warncell ID / name is required.", + "data": { + "region_identifier": "Warncell ID or name" + } + } + }, + "error": { + "invalid_identifier": "The specified region identifier is invalid." + }, + "abort": { + "already_configured": "Warncell ID / name is already configured.", + "invalid_identifier": "[%key:component::dwd_weather_warnings::config::error::invalid_identifier%]" + } + }, + "issues": { + "deprecated_yaml": { + "title": "The Deutscher Wetterdienst (DWD) Weather Warnings YAML configuration is being removed", + "description": "Configuring Deutscher Wetterdienst (DWD) Weather Warnings using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the Deutscher Wetterdienst (DWD) Weather Warnings YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 9506be28872a..1372e94d66df 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -103,6 +103,7 @@ FLOWS = { "dsmr", "dsmr_reader", "dunehd", + "dwd_weather_warnings", "dynalite", "eafm", "easyenergy", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 22b89430ed61..e00a9e75ce95 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1200,7 +1200,7 @@ "dwd_weather_warnings": { "name": "Deutscher Wetterdienst (DWD) Weather Warnings", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "dweet": { diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 85cccb4e391c..f9deacd7c723 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -493,6 +493,9 @@ doorbirdpy==2.1.0 # homeassistant.components.dsmr dsmr_parser==0.33 +# homeassistant.components.dwd_weather_warnings +dwdwfsapi==1.0.6 + # homeassistant.components.dynalite dynalite_devices==0.1.47 diff --git a/tests/components/dwd_weather_warnings/__init__.py b/tests/components/dwd_weather_warnings/__init__.py new file mode 100644 index 000000000000..03d27d285033 --- /dev/null +++ b/tests/components/dwd_weather_warnings/__init__.py @@ -0,0 +1 @@ +"""Tests for Deutscher Wetterdienst (DWD) Weather Warnings.""" diff --git a/tests/components/dwd_weather_warnings/conftest.py b/tests/components/dwd_weather_warnings/conftest.py new file mode 100644 index 000000000000..a09f6cb2fb3c --- /dev/null +++ b/tests/components/dwd_weather_warnings/conftest.py @@ -0,0 +1,16 @@ +"""Configuration for Deutscher Wetterdienst (DWD) Weather Warnings 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.dwd_weather_warnings.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/dwd_weather_warnings/test_config_flow.py b/tests/components/dwd_weather_warnings/test_config_flow.py new file mode 100644 index 000000000000..9c730eacffdf --- /dev/null +++ b/tests/components/dwd_weather_warnings/test_config_flow.py @@ -0,0 +1,200 @@ +"""Tests for Deutscher Wetterdienst (DWD) Weather Warnings config flow.""" + +from typing import Final +from unittest.mock import patch + +import pytest + +from homeassistant.components.dwd_weather_warnings.const import ( + ADVANCE_WARNING_SENSOR, + CONF_REGION_IDENTIFIER, + CONF_REGION_NAME, + CURRENT_WARNING_SENSOR, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_MONITORED_CONDITIONS, CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +DEMO_CONFIG_ENTRY: Final = { + CONF_REGION_IDENTIFIER: "807111000", +} + +DEMO_YAML_CONFIGURATION: Final = { + CONF_NAME: "Unit Test", + CONF_REGION_NAME: "807111000", + CONF_MONITORED_CONDITIONS: [CURRENT_WARNING_SENSOR, ADVANCE_WARNING_SENSOR], +} + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +async def test_create_entry(hass: HomeAssistant) -> None: + """Test that the full config flow works.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + + with patch( + "homeassistant.components.dwd_weather_warnings.config_flow.DwdWeatherWarningsAPI", + return_value=False, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=DEMO_CONFIG_ENTRY + ) + + # Test for invalid region identifier. + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "invalid_identifier"} + + with patch( + "homeassistant.components.dwd_weather_warnings.config_flow.DwdWeatherWarningsAPI", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=DEMO_CONFIG_ENTRY + ) + + # Test for successfully created entry. + await hass.async_block_till_done() + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "DWD Weather Warnings 807111000" + assert result["data"] == { + CONF_REGION_IDENTIFIER: "807111000", + } + + +async def test_import_flow_full_data(hass: HomeAssistant) -> None: + """Test import of a full YAML configuration with both success and failure.""" + # Test abort due to invalid identifier. + with patch( + "homeassistant.components.dwd_weather_warnings.config_flow.DwdWeatherWarningsAPI", + return_value=False, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=DEMO_YAML_CONFIGURATION.copy(), + ) + + await hass.async_block_till_done() + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "invalid_identifier" + + # Test successful import. + with patch( + "homeassistant.components.dwd_weather_warnings.config_flow.DwdWeatherWarningsAPI", + return_value=True, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=DEMO_YAML_CONFIGURATION.copy(), + ) + + await hass.async_block_till_done() + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Unit Test" + assert result["data"] == { + CONF_NAME: "Unit Test", + CONF_REGION_IDENTIFIER: "807111000", + CONF_MONITORED_CONDITIONS: [CURRENT_WARNING_SENSOR, ADVANCE_WARNING_SENSOR], + } + + +async def test_import_flow_no_name(hass: HomeAssistant) -> None: + """Test a successful import of a YAML configuration with no name set.""" + data = DEMO_YAML_CONFIGURATION.copy() + data.pop(CONF_NAME) + + with patch( + "homeassistant.components.dwd_weather_warnings.config_flow.DwdWeatherWarningsAPI", + return_value=True, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=data + ) + + await hass.async_block_till_done() + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "DWD Weather Warnings 807111000" + assert result["data"] == { + CONF_REGION_IDENTIFIER: "807111000", + CONF_MONITORED_CONDITIONS: [CURRENT_WARNING_SENSOR, ADVANCE_WARNING_SENSOR], + } + + +async def test_import_flow_only_required(hass: HomeAssistant) -> None: + """Test a successful import of a YAML configuration with only required properties.""" + data = DEMO_YAML_CONFIGURATION.copy() + data.pop(CONF_NAME) + data.pop(CONF_MONITORED_CONDITIONS) + + with patch( + "homeassistant.components.dwd_weather_warnings.config_flow.DwdWeatherWarningsAPI", + return_value=True, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=data + ) + + await hass.async_block_till_done() + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "DWD Weather Warnings 807111000" + assert result["data"] == { + CONF_REGION_IDENTIFIER: "807111000", + } + + +async def test_import_flow_already_configured(hass: HomeAssistant) -> None: + """Test aborting, if the warncell ID / name is already configured during the import.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=DEMO_CONFIG_ENTRY.copy(), + unique_id=DEMO_CONFIG_ENTRY[CONF_REGION_IDENTIFIER], + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=DEMO_YAML_CONFIGURATION.copy() + ) + + await hass.async_block_till_done() + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_config_flow_already_configured(hass: HomeAssistant) -> None: + """Test aborting, if the warncell ID / name is already configured during the config.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=DEMO_CONFIG_ENTRY.copy(), + unique_id=DEMO_CONFIG_ENTRY[CONF_REGION_IDENTIFIER], + ) + entry.add_to_hass(hass) + + # Start configuration of duplicate entry. + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + + with patch( + "homeassistant.components.dwd_weather_warnings.config_flow.DwdWeatherWarningsAPI", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=DEMO_CONFIG_ENTRY + ) + + await hass.async_block_till_done() + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/dwd_weather_warnings/test_init.py b/tests/components/dwd_weather_warnings/test_init.py new file mode 100644 index 000000000000..6967f2ca6b1a --- /dev/null +++ b/tests/components/dwd_weather_warnings/test_init.py @@ -0,0 +1,38 @@ +"""Tests for Deutscher Wetterdienst (DWD) Weather Warnings integration.""" + +from typing import Final + +from homeassistant.components.dwd_weather_warnings.const import ( + ADVANCE_WARNING_SENSOR, + CONF_REGION_IDENTIFIER, + CURRENT_WARNING_SENSOR, + DOMAIN, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_MONITORED_CONDITIONS, CONF_NAME +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +DEMO_CONFIG_ENTRY: Final = { + CONF_NAME: "Unit Test", + CONF_REGION_IDENTIFIER: "807111000", + CONF_MONITORED_CONDITIONS: [CURRENT_WARNING_SENSOR, ADVANCE_WARNING_SENSOR], +} + + +async def test_load_unload_entry(hass: HomeAssistant) -> None: + """Test loading and unloading the integration.""" + entry = MockConfigEntry(domain=DOMAIN, data=DEMO_CONFIG_ENTRY) + 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 entry.entry_id in hass.data[DOMAIN] + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.NOT_LOADED + assert entry.entry_id not in hass.data[DOMAIN] From 16c915864b872a5cbf5b87288275a8d385d5e358 Mon Sep 17 00:00:00 2001 From: Austin Mroczek Date: Sun, 7 May 2023 01:27:33 -0700 Subject: [PATCH 256/822] Add diagnostic sensors for TotalConnect (#73152) * add diagnostic sensors * test binary_sensor.py file * add tests for binary sensor * fix zone type checks and error on unknown * improve entity tests * hide entities by default * Revert "hide entities by default" This reverts commit 9808d732471385e45ccc5f7c3aea93bfecbdfa6f. * Update homeassistant/components/totalconnect/binary_sensor.py Co-authored-by: Paulus Schoutsen * update binary_sensor per comments * update test * move to _attr_extra_state_attributes * no spaces in unique_id * update per balloob suggestions * fix typing * fix black and mypy * Apply suggestions from code review Co-authored-by: G Johansson * add more to binary_sensor tests * remove unused import --------- Co-authored-by: Paulus Schoutsen Co-authored-by: G Johansson --- .coveragerc | 1 - .../components/totalconnect/binary_sensor.py | 188 +++++++++++++----- tests/components/totalconnect/common.py | 49 ++++- .../totalconnect/test_binary_sensor.py | 86 ++++++++ 4 files changed, 274 insertions(+), 50 deletions(-) create mode 100644 tests/components/totalconnect/test_binary_sensor.py diff --git a/.coveragerc b/.coveragerc index 1bb8bdcb6875..834232c90258 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1293,7 +1293,6 @@ omit = homeassistant/components/toon/switch.py homeassistant/components/torque/sensor.py homeassistant/components/totalconnect/__init__.py - homeassistant/components/totalconnect/binary_sensor.py homeassistant/components/touchline/climate.py homeassistant/components/tplink_lte/* homeassistant/components/tplink_omada/__init__.py diff --git a/homeassistant/components/totalconnect/binary_sensor.py b/homeassistant/components/totalconnect/binary_sensor.py index 32e0b3573f5a..ef252d54e4e7 100644 --- a/homeassistant/components/totalconnect/binary_sensor.py +++ b/homeassistant/components/totalconnect/binary_sensor.py @@ -1,74 +1,78 @@ """Interfaces with TotalConnect sensors.""" +import logging + 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 +LOW_BATTERY = "low_battery" +TAMPER = "tamper" +POWER = "power" +ZONE = "zone" + +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up TotalConnect device sensors based on a config entry.""" - sensors = [] + sensors: list = [] client_locations = hass.data[DOMAIN][entry.entry_id].client.locations for location_id, location in client_locations.items(): - for zone_id, zone in location.zones.items(): - sensors.append(TotalConnectBinarySensor(zone_id, location_id, zone)) + sensors.append(TotalConnectAlarmLowBatteryBinarySensor(location)) + sensors.append(TotalConnectAlarmTamperBinarySensor(location)) + sensors.append(TotalConnectAlarmPowerBinarySensor(location)) + + for zone in location.zones.values(): + sensors.append(TotalConnectZoneSecurityBinarySensor(location_id, zone)) + + if not zone.is_type_button(): + sensors.append(TotalConnectLowBatteryBinarySensor(location_id, zone)) + sensors.append(TotalConnectTamperBinarySensor(location_id, zone)) async_add_entities(sensors, True) -class TotalConnectBinarySensor(BinarySensorEntity): +class TotalConnectZoneBinarySensor(BinarySensorEntity): """Represent an TotalConnect zone.""" - def __init__(self, zone_id, location_id, zone): + def __init__(self, location_id, zone): """Initialize the TotalConnect status.""" - self._zone_id = zone_id self._location_id = location_id self._zone = zone - self._name = self._zone.description - self._unique_id = f"{location_id} {zone_id}" - self._is_on = None - self._is_tampered = None - self._is_low_battery = None + self._attr_name = f"{zone.description}{self.entity_description.name}" + self._attr_unique_id = ( + f"{location_id}_{zone.zoneid}_{self.entity_description.key}" + ) + self._attr_is_on = None + self._attr_extra_state_attributes = { + "zone_id": self._zone.zoneid, + "location_id": self._location_id, + "partition": self._zone.partition, + } - @property - def unique_id(self): - """Return the unique id.""" - return self._unique_id - @property - def name(self): - """Return the name of the device.""" - return self._name +class TotalConnectZoneSecurityBinarySensor(TotalConnectZoneBinarySensor): + """Represent an TotalConnect security zone.""" - def update(self) -> None: - """Return the state of the device.""" - self._is_tampered = self._zone.is_tampered() - self._is_low_battery = self._zone.is_low_battery() - - if self._zone.is_faulted() or self._zone.is_triggered(): - self._is_on = True - else: - self._is_on = False - - @property - def is_on(self): - """Return true if the binary sensor is on.""" - return self._is_on + entity_description: BinarySensorEntityDescription = BinarySensorEntityDescription( + key=ZONE, name="" + ) @property def device_class(self): - """Return the class of this device, from BinarySensorDeviceClass.""" - if self._zone.is_type_security(): - return BinarySensorDeviceClass.DOOR + """Return the class of this zone.""" if self._zone.is_type_fire(): return BinarySensorDeviceClass.SMOKE if self._zone.is_type_carbon_monoxide(): @@ -77,16 +81,108 @@ class TotalConnectBinarySensor(BinarySensorEntity): return BinarySensorDeviceClass.MOTION if self._zone.is_type_medical(): return BinarySensorDeviceClass.SAFETY + # "security" type is a generic category so test for it last + if self._zone.is_type_security(): + return BinarySensorDeviceClass.DOOR + + _LOGGER.error( + "TotalConnect zone %s reported an unexpected device class", + self._zone.zoneid, + ) return None - @property - def extra_state_attributes(self): - """Return the state attributes.""" - attributes = { - "zone_id": self._zone_id, - "location_id": self._location_id, - "low_battery": self._is_low_battery, - "tampered": self._is_tampered, - "partition": self._zone.partition, + def update(self): + """Return the state of the device.""" + if self._zone.is_faulted() or self._zone.is_triggered(): + self._attr_is_on = True + else: + self._attr_is_on = False + + +class TotalConnectLowBatteryBinarySensor(TotalConnectZoneBinarySensor): + """Represent an TotalConnect zone low battery status.""" + + entity_description: BinarySensorEntityDescription = BinarySensorEntityDescription( + key=LOW_BATTERY, + device_class=BinarySensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + name=" low battery", + ) + + def update(self): + """Return the state of the device.""" + self._attr_is_on = self._zone.is_low_battery() + + +class TotalConnectTamperBinarySensor(TotalConnectZoneBinarySensor): + """Represent an TotalConnect zone tamper status.""" + + entity_description: BinarySensorEntityDescription = BinarySensorEntityDescription( + key=TAMPER, + device_class=BinarySensorDeviceClass.TAMPER, + entity_category=EntityCategory.DIAGNOSTIC, + name=f" {TAMPER}", + ) + + def update(self): + """Return the state of the device.""" + self._attr_is_on = self._zone.is_tampered() + + +class TotalConnectAlarmBinarySensor(BinarySensorEntity): + """Represent an TotalConnect alarm device binary sensors.""" + + def __init__(self, location): + """Initialize the TotalConnect alarm device binary sensor.""" + self._location = location + self._attr_name = f"{location.location_name}{self.entity_description.name}" + self._attr_unique_id = f"{location.location_id}_{self.entity_description.key}" + self._attr_is_on = None + self._attr_extra_state_attributes = { + "location_id": self._location.location_id, } - return attributes + + +class TotalConnectAlarmLowBatteryBinarySensor(TotalConnectAlarmBinarySensor): + """Represent an TotalConnect Alarm low battery status.""" + + entity_description: BinarySensorEntityDescription = BinarySensorEntityDescription( + key=LOW_BATTERY, + device_class=BinarySensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + name=" low battery", + ) + + def update(self): + """Return the state of the device.""" + self._attr_is_on = self._location.is_low_battery() + + +class TotalConnectAlarmTamperBinarySensor(TotalConnectAlarmBinarySensor): + """Represent an TotalConnect alarm tamper status.""" + + entity_description: BinarySensorEntityDescription = BinarySensorEntityDescription( + key=TAMPER, + device_class=BinarySensorDeviceClass.TAMPER, + entity_category=EntityCategory.DIAGNOSTIC, + name=f" {TAMPER}", + ) + + def update(self): + """Return the state of the device.""" + self._attr_is_on = self._location.is_cover_tampered() + + +class TotalConnectAlarmPowerBinarySensor(TotalConnectAlarmBinarySensor): + """Represent an TotalConnect alarm power status.""" + + entity_description: BinarySensorEntityDescription = BinarySensorEntityDescription( + key=POWER, + device_class=BinarySensorDeviceClass.POWER, + entity_category=EntityCategory.DIAGNOSTIC, + name=f" {POWER}", + ) + + def update(self): + """Return the state of the device.""" + self._attr_is_on = not self._location.is_ac_loss() diff --git a/tests/components/totalconnect/common.py b/tests/components/totalconnect/common.py index 65b10718fd54..54f321c67703 100644 --- a/tests/components/totalconnect/common.py +++ b/tests/components/totalconnect/common.py @@ -148,12 +148,55 @@ PARTITIONS_UNKNOWN = {"PartitionInfo": PARTITION_INFO_UNKNOWN} ZONE_NORMAL = { "ZoneID": "1", - "ZoneDescription": "Normal", - "ZoneStatus": ZoneStatus.NORMAL, + "ZoneDescription": "Security", + "ZoneStatus": ZoneStatus.FAULT, + "ZoneTypeId": ZoneType.SECURITY, "PartitionId": "1", + "CanBeBypassed": 1, +} +ZONE_2 = { + "ZoneID": "2", + "ZoneDescription": "Fire", + "ZoneStatus": ZoneStatus.LOW_BATTERY, + "ZoneTypeId": ZoneType.FIRE_SMOKE, + "PartitionId": "1", + "CanBeBypassed": 1, +} +ZONE_3 = { + "ZoneID": "3", + "ZoneDescription": "Gas", + "ZoneStatus": ZoneStatus.TAMPER, + "ZoneTypeId": ZoneType.CARBON_MONOXIDE, + "PartitionId": "1", + "CanBeBypassed": 1, +} +ZONE_4 = { + "ZoneID": "4", + "ZoneDescription": "Motion", + "ZoneStatus": ZoneStatus.NORMAL, + "ZoneTypeId": ZoneType.INTERIOR_FOLLOWER, + "PartitionId": "1", + "CanBeBypassed": 1, +} +ZONE_5 = { + "ZoneID": "5", + "ZoneDescription": "Medical", + "ZoneStatus": ZoneStatus.NORMAL, + "ZoneTypeId": ZoneType.PROA7_MEDICAL, + "PartitionId": "1", + "CanBeBypassed": 0, +} +# 99 is an unknown ZoneType +ZONE_6 = { + "ZoneID": "6", + "ZoneDescription": "Medical", + "ZoneStatus": ZoneStatus.NORMAL, + "ZoneTypeId": 99, + "PartitionId": "1", + "CanBeBypassed": 0, } -ZONE_INFO = [ZONE_NORMAL] +ZONE_INFO = [ZONE_NORMAL, ZONE_2, ZONE_3, ZONE_4, ZONE_5, ZONE_6] ZONES = {"ZoneInfo": ZONE_INFO} METADATA_DISARMED = { diff --git a/tests/components/totalconnect/test_binary_sensor.py b/tests/components/totalconnect/test_binary_sensor.py new file mode 100644 index 000000000000..966daeb5a63e --- /dev/null +++ b/tests/components/totalconnect/test_binary_sensor.py @@ -0,0 +1,86 @@ +"""Tests for the TotalConnect binary sensor.""" +from unittest.mock import patch + +from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR, + BinarySensorDeviceClass, +) +from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .common import LOCATION_ID, RESPONSE_DISARMED, ZONE_NORMAL, setup_platform + +ZONE_ENTITY_ID = "binary_sensor.security" +ZONE_LOW_BATTERY_ID = "binary_sensor.security_low_battery" +ZONE_TAMPER_ID = "binary_sensor.security_tamper" +PANEL_BATTERY_ID = "binary_sensor.test_low_battery" +PANEL_TAMPER_ID = "binary_sensor.test_tamper" +PANEL_POWER_ID = "binary_sensor.test_power" + + +async def test_entity_registry(hass: HomeAssistant) -> None: + """Test the binary sensor is registered in entity registry.""" + await setup_platform(hass, BINARY_SENSOR) + entity_registry = er.async_get(hass) + + # ensure zone 1 plus two diagnostic zones are created + entry = entity_registry.async_get(ZONE_ENTITY_ID) + entry_low_battery = entity_registry.async_get(ZONE_LOW_BATTERY_ID) + entry_tamper = entity_registry.async_get(ZONE_TAMPER_ID) + + assert entry.unique_id == f"{LOCATION_ID}_{ZONE_NORMAL['ZoneID']}_zone" + assert ( + entry_low_battery.unique_id + == f"{LOCATION_ID}_{ZONE_NORMAL['ZoneID']}_low_battery" + ) + assert entry_tamper.unique_id == f"{LOCATION_ID}_{ZONE_NORMAL['ZoneID']}_tamper" + + # ensure panel diagnostic zones are created + panel_battery = entity_registry.async_get(PANEL_BATTERY_ID) + panel_tamper = entity_registry.async_get(PANEL_TAMPER_ID) + panel_power = entity_registry.async_get(PANEL_POWER_ID) + + assert panel_battery.unique_id == f"{LOCATION_ID}_low_battery" + assert panel_tamper.unique_id == f"{LOCATION_ID}_tamper" + assert panel_power.unique_id == f"{LOCATION_ID}_power" + + +async def test_state_and_attributes(hass: HomeAssistant) -> None: + """Test the binary sensor attributes are correct.""" + + with patch( + "homeassistant.components.totalconnect.TotalConnectClient.request", + return_value=RESPONSE_DISARMED, + ): + await setup_platform(hass, BINARY_SENSOR) + + state = hass.states.get(ZONE_ENTITY_ID) + assert state.state == STATE_ON + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) == ZONE_NORMAL["ZoneDescription"] + ) + assert state.attributes.get("device_class") == BinarySensorDeviceClass.DOOR + + state = hass.states.get(f"{ZONE_ENTITY_ID}_low_battery") + assert state.state == STATE_OFF + state = hass.states.get(f"{ZONE_ENTITY_ID}_tamper") + assert state.state == STATE_OFF + + # Zone 2 is fire with low battery + state = hass.states.get("binary_sensor.fire") + assert state.state == STATE_OFF + assert state.attributes.get("device_class") == BinarySensorDeviceClass.SMOKE + state = hass.states.get("binary_sensor.fire_low_battery") + assert state.state == STATE_ON + state = hass.states.get("binary_sensor.fire_tamper") + assert state.state == STATE_OFF + + # Zone 3 is gas with tamper + state = hass.states.get("binary_sensor.gas") + assert state.state == STATE_OFF + assert state.attributes.get("device_class") == BinarySensorDeviceClass.GAS + state = hass.states.get("binary_sensor.gas_low_battery") + assert state.state == STATE_OFF + state = hass.states.get("binary_sensor.gas_tamper") + assert state.state == STATE_ON From 8c67e96e38bff3155736cd3db5fe3be480db23c8 Mon Sep 17 00:00:00 2001 From: Gerard Date: Sun, 7 May 2023 12:46:00 +0200 Subject: [PATCH 257/822] Add AC current limit and Pre entry climatization (#92688) * Add AC current limit and Pre entry climatization Additional information available for EVs * Add pre entry climatization * Fix mypy error * Change for black and mypy --- .../components/bmw_connected_drive/binary_sensor.py | 8 ++++++++ homeassistant/components/bmw_connected_drive/sensor.py | 10 +++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/bmw_connected_drive/binary_sensor.py b/homeassistant/components/bmw_connected_drive/binary_sensor.py index df25efb6d5e5..640f4e3653be 100644 --- a/homeassistant/components/bmw_connected_drive/binary_sensor.py +++ b/homeassistant/components/bmw_connected_drive/binary_sensor.py @@ -189,6 +189,14 @@ SENSOR_TYPES: tuple[BMWBinarySensorEntityDescription, ...] = ( icon="mdi:car-electric", value_fn=lambda v: v.fuel_and_battery.is_charger_connected, ), + BMWBinarySensorEntityDescription( + key="is_pre_entry_climatization_enabled", + name="Pre entry climatization", + icon="mdi:car-seat-heater", + value_fn=lambda v: v.charging_profile.is_pre_entry_climatization_enabled + if v.charging_profile + else False, + ), ) diff --git a/homeassistant/components/bmw_connected_drive/sensor.py b/homeassistant/components/bmw_connected_drive/sensor.py index b15a95fde245..314ff47c14cf 100644 --- a/homeassistant/components/bmw_connected_drive/sensor.py +++ b/homeassistant/components/bmw_connected_drive/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( SensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import LENGTH, PERCENTAGE, VOLUME +from homeassistant.const import LENGTH, PERCENTAGE, VOLUME, UnitOfElectricCurrent from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -53,6 +53,14 @@ def convert_and_round( SENSOR_TYPES: dict[str, BMWSensorEntityDescription] = { # --- Generic --- + "ac_current_limit": BMWSensorEntityDescription( + key="ac_current_limit", + name="AC current limit", + key_class="charging_profile", + unit_type=UnitOfElectricCurrent.AMPERE, + icon="mdi:current-ac", + entity_registry_enabled_default=False, + ), "charging_start_time": BMWSensorEntityDescription( key="charging_start_time", name="Charging start time", From f165a41b7ad4c6a715d6f423cf0a7e703ff7e8b2 Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Sun, 7 May 2023 14:53:46 +0200 Subject: [PATCH 258/822] Add button platform to devolo Home Network (#85834) * Add Start WPS button * Add remaining buttons * Set correct entity categories * Inherit from DevoloEntity * Fix mypy * Apply feedback * Raise on DevicePasswordProtected * Fix ruff * Really fix ruff * Adapt to recent development * Change error message --- .../devolo_home_network/__init__.py | 2 +- .../devolo_home_network/binary_sensor.py | 6 +- .../components/devolo_home_network/button.py | 133 ++++++++++ .../components/devolo_home_network/const.py | 5 +- .../components/devolo_home_network/entity.py | 23 +- .../components/devolo_home_network/sensor.py | 4 +- .../devolo_home_network/strings.json | 14 + .../components/devolo_home_network/switch.py | 4 +- tests/components/devolo_home_network/const.py | 2 +- tests/components/devolo_home_network/mock.py | 4 + .../devolo_home_network/test_button.py | 242 ++++++++++++++++++ .../devolo_home_network/test_init.py | 9 +- 12 files changed, 430 insertions(+), 18 deletions(-) create mode 100644 homeassistant/components/devolo_home_network/button.py create mode 100644 tests/components/devolo_home_network/test_button.py diff --git a/homeassistant/components/devolo_home_network/__init__.py b/homeassistant/components/devolo_home_network/__init__.py index d2c7b62a3998..00d96ea53b33 100644 --- a/homeassistant/components/devolo_home_network/__init__.py +++ b/homeassistant/components/devolo_home_network/__init__.py @@ -185,7 +185,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @callback def platforms(device: Device) -> set[Platform]: """Assemble supported platforms.""" - supported_platforms = {Platform.SENSOR, Platform.SWITCH} + supported_platforms = {Platform.BUTTON, Platform.SENSOR, Platform.SWITCH} if device.plcnet: supported_platforms.add(Platform.BINARY_SENSOR) if device.device and "wifi1" in device.device.features: diff --git a/homeassistant/components/devolo_home_network/binary_sensor.py b/homeassistant/components/devolo_home_network/binary_sensor.py index b8f2551a891e..ebe7e60af7bb 100644 --- a/homeassistant/components/devolo_home_network/binary_sensor.py +++ b/homeassistant/components/devolo_home_network/binary_sensor.py @@ -20,7 +20,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import CONNECTED_PLC_DEVICES, CONNECTED_TO_ROUTER, DOMAIN -from .entity import DevoloEntity +from .entity import DevoloCoordinatorEntity def _is_connected_to_router(entity: DevoloBinarySensorEntity) -> bool: @@ -79,7 +79,9 @@ async def async_setup_entry( async_add_entities(entities) -class DevoloBinarySensorEntity(DevoloEntity[LogicalNetwork], BinarySensorEntity): +class DevoloBinarySensorEntity( + DevoloCoordinatorEntity[LogicalNetwork], BinarySensorEntity +): """Representation of a devolo binary sensor.""" def __init__( diff --git a/homeassistant/components/devolo_home_network/button.py b/homeassistant/components/devolo_home_network/button.py new file mode 100644 index 000000000000..463356268a64 --- /dev/null +++ b/homeassistant/components/devolo_home_network/button.py @@ -0,0 +1,133 @@ +"""Platform for button integration.""" +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + +from devolo_plc_api.device import Device +from devolo_plc_api.exceptions.device import DevicePasswordProtected, DeviceUnavailable + +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.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, IDENTIFY, PAIRING, RESTART, START_WPS +from .entity import DevoloEntity + + +@dataclass +class DevoloButtonRequiredKeysMixin: + """Mixin for required keys.""" + + press_func: Callable[[Device], Awaitable[bool]] + + +@dataclass +class DevoloButtonEntityDescription( + ButtonEntityDescription, DevoloButtonRequiredKeysMixin +): + """Describes devolo button entity.""" + + +BUTTON_TYPES: dict[str, DevoloButtonEntityDescription] = { + IDENTIFY: DevoloButtonEntityDescription( + key=IDENTIFY, + entity_category=EntityCategory.DIAGNOSTIC, + icon="mdi:led-on", + press_func=lambda device: device.plcnet.async_identify_device_start(), # type: ignore[union-attr] + ), + PAIRING: DevoloButtonEntityDescription( + key=PAIRING, + icon="mdi:plus-network-outline", + press_func=lambda device: device.plcnet.async_pair_device(), # type: ignore[union-attr] + ), + RESTART: DevoloButtonEntityDescription( + key=RESTART, + device_class=ButtonDeviceClass.RESTART, + entity_category=EntityCategory.CONFIG, + press_func=lambda device: device.device.async_restart(), # type: ignore[union-attr] + ), + START_WPS: DevoloButtonEntityDescription( + key=START_WPS, + icon="mdi:wifi-plus", + press_func=lambda device: device.device.async_start_wps(), # type: ignore[union-attr] + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Get all devices and buttons and setup them via config entry.""" + device: Device = hass.data[DOMAIN][entry.entry_id]["device"] + + entities: list[DevoloButtonEntity] = [] + if device.plcnet: + entities.append( + DevoloButtonEntity( + entry, + BUTTON_TYPES[IDENTIFY], + device, + ) + ) + entities.append( + DevoloButtonEntity( + entry, + BUTTON_TYPES[PAIRING], + device, + ) + ) + if device.device and "restart" in device.device.features: + entities.append( + DevoloButtonEntity( + entry, + BUTTON_TYPES[RESTART], + device, + ) + ) + if device.device and "wifi1" in device.device.features: + entities.append( + DevoloButtonEntity( + entry, + BUTTON_TYPES[START_WPS], + device, + ) + ) + async_add_entities(entities) + + +class DevoloButtonEntity(DevoloEntity, ButtonEntity): + """Representation of a devolo button.""" + + entity_description: DevoloButtonEntityDescription + + def __init__( + self, + entry: ConfigEntry, + description: DevoloButtonEntityDescription, + device: Device, + ) -> None: + """Initialize entity.""" + self.entity_description = description + super().__init__(entry, device) + + async def async_press(self) -> None: + """Handle the button press.""" + try: + await self.entity_description.press_func(self.device) + 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" + ) from ex + except DeviceUnavailable as ex: + raise HomeAssistantError( + f"Device {self.entry.title} did not respond" + ) from ex diff --git a/homeassistant/components/devolo_home_network/const.py b/homeassistant/components/devolo_home_network/const.py index 193a0dc9a152..39016ac79166 100644 --- a/homeassistant/components/devolo_home_network/const.py +++ b/homeassistant/components/devolo_home_network/const.py @@ -10,7 +10,6 @@ from devolo_plc_api.device_api import ( ) DOMAIN = "devolo_home_network" - PRODUCT = "product" SERIAL_NUMBER = "serial_number" TITLE = "title" @@ -21,7 +20,11 @@ SHORT_UPDATE_INTERVAL = timedelta(seconds=15) CONNECTED_PLC_DEVICES = "connected_plc_devices" CONNECTED_TO_ROUTER = "connected_to_router" CONNECTED_WIFI_CLIENTS = "connected_wifi_clients" +IDENTIFY = "identify" NEIGHBORING_WIFI_NETWORKS = "neighboring_wifi_networks" +PAIRING = "pairing" +RESTART = "restart" +START_WPS = "start_wps" SWITCH_GUEST_WIFI = "switch_guest_wifi" SWITCH_LEDS = "switch_leds" diff --git a/homeassistant/components/devolo_home_network/entity.py b/homeassistant/components/devolo_home_network/entity.py index 8b665d7bf024..e477df63bd22 100644 --- a/homeassistant/components/devolo_home_network/entity.py +++ b/homeassistant/components/devolo_home_network/entity.py @@ -12,7 +12,7 @@ from devolo_plc_api.device_api import ( from devolo_plc_api.plcnet_api import LogicalNetwork from homeassistant.config_entries import ConfigEntry -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity import DeviceInfo, Entity from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -32,7 +32,7 @@ _DataT = TypeVar( ) -class DevoloEntity(CoordinatorEntity[DataUpdateCoordinator[_DataT]]): +class DevoloEntity(Entity): """Representation of a devolo home network device.""" _attr_has_entity_name = True @@ -40,12 +40,9 @@ class DevoloEntity(CoordinatorEntity[DataUpdateCoordinator[_DataT]]): def __init__( self, entry: ConfigEntry, - coordinator: DataUpdateCoordinator[_DataT], device: Device, ) -> None: """Initialize a devolo home network device.""" - super().__init__(coordinator) - self.device = device self.entry = entry @@ -59,3 +56,19 @@ class DevoloEntity(CoordinatorEntity[DataUpdateCoordinator[_DataT]]): ) self._attr_translation_key = self.entity_description.key self._attr_unique_id = f"{device.serial_number}_{self.entity_description.key}" + + +class DevoloCoordinatorEntity( + CoordinatorEntity[DataUpdateCoordinator[_DataT]], DevoloEntity +): + """Representation of a coordinated devolo home network device.""" + + def __init__( + self, + entry: ConfigEntry, + coordinator: DataUpdateCoordinator[_DataT], + device: Device, + ) -> None: + """Initialize a devolo home network device.""" + super().__init__(coordinator) + DevoloEntity.__init__(self, entry, device) diff --git a/homeassistant/components/devolo_home_network/sensor.py b/homeassistant/components/devolo_home_network/sensor.py index aeeab2ce89b0..7a6da1f41a57 100644 --- a/homeassistant/components/devolo_home_network/sensor.py +++ b/homeassistant/components/devolo_home_network/sensor.py @@ -26,7 +26,7 @@ from .const import ( DOMAIN, NEIGHBORING_WIFI_NETWORKS, ) -from .entity import DevoloEntity +from .entity import DevoloCoordinatorEntity _DataT = TypeVar( "_DataT", @@ -113,7 +113,7 @@ async def async_setup_entry( async_add_entities(entities) -class DevoloSensorEntity(DevoloEntity[_DataT], SensorEntity): +class DevoloSensorEntity(DevoloCoordinatorEntity[_DataT], SensorEntity): """Representation of a devolo sensor.""" entity_description: DevoloSensorEntityDescription[_DataT] diff --git a/homeassistant/components/devolo_home_network/strings.json b/homeassistant/components/devolo_home_network/strings.json index 3472886cd5b0..e2954c1c7ecc 100644 --- a/homeassistant/components/devolo_home_network/strings.json +++ b/homeassistant/components/devolo_home_network/strings.json @@ -34,6 +34,20 @@ "name": "Connected to router" } }, + "button": { + "identify": { + "name": "Identify device with a blinking LED" + }, + "pairing": { + "name": "Start PLC pairing" + }, + "restart": { + "name": "Restart device" + }, + "start_wps": { + "name": "Start WPS" + } + }, "sensor": { "connected_plc_devices": { "name": "Connected PLC devices" diff --git a/homeassistant/components/devolo_home_network/switch.py b/homeassistant/components/devolo_home_network/switch.py index 6f387fdf05f4..8f82cf8f416f 100644 --- a/homeassistant/components/devolo_home_network/switch.py +++ b/homeassistant/components/devolo_home_network/switch.py @@ -17,7 +17,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN, SWITCH_GUEST_WIFI, SWITCH_LEDS -from .entity import DevoloEntity +from .entity import DevoloCoordinatorEntity _DataT = TypeVar("_DataT", bound=WifiGuestAccessGet | bool) @@ -88,7 +88,7 @@ async def async_setup_entry( async_add_entities(entities) -class DevoloSwitchEntity(DevoloEntity[_DataT], SwitchEntity): +class DevoloSwitchEntity(DevoloCoordinatorEntity[_DataT], SwitchEntity): """Representation of a devolo switch.""" entity_description: DevoloSwitchEntityDescription[_DataT] diff --git a/tests/components/devolo_home_network/const.py b/tests/components/devolo_home_network/const.py index 75e6a57e1d47..fe11a55eb85e 100644 --- a/tests/components/devolo_home_network/const.py +++ b/tests/components/devolo_home_network/const.py @@ -38,7 +38,7 @@ DISCOVERY_INFO = ZeroconfServiceInfo( "Path": "abcdefghijkl/deviceapi", "Version": "v0", "Product": "dLAN pro 1200+ WiFi ac", - "Features": "reset,update,led,intmtg,wifi1", + "Features": "intmtg1,led,reset,restart,update,wifi1", "MT": "2730", "SN": "1234567890", "FirmwareVersion": "5.6.1", diff --git a/tests/components/devolo_home_network/mock.py b/tests/components/devolo_home_network/mock.py index 0ea985a48c74..1cced53a520e 100644 --- a/tests/components/devolo_home_network/mock.py +++ b/tests/components/devolo_home_network/mock.py @@ -51,6 +51,8 @@ class MockDevice(Device): self.async_disconnect = AsyncMock() self.device = DeviceApi(IP, None, DISCOVERY_INFO) self.device.async_get_led_setting = AsyncMock(return_value=False) + self.device.async_restart = AsyncMock(return_value=True) + self.device.async_start_wps = AsyncMock(return_value=True) self.device.async_get_wifi_connected_station = AsyncMock( return_value=CONNECTED_STATIONS ) @@ -60,3 +62,5 @@ class MockDevice(Device): ) self.plcnet = PlcNetApi(IP, None, DISCOVERY_INFO) self.plcnet.async_get_network_overview = AsyncMock(return_value=PLCNET) + self.plcnet.async_identify_device_start = AsyncMock(return_value=True) + self.plcnet.async_pair_device = AsyncMock(return_value=True) diff --git a/tests/components/devolo_home_network/test_button.py b/tests/components/devolo_home_network/test_button.py new file mode 100644 index 000000000000..69252a7c5080 --- /dev/null +++ b/tests/components/devolo_home_network/test_button.py @@ -0,0 +1,242 @@ +"""Tests for the devolo Home Network buttons.""" +from unittest.mock import AsyncMock + +from devolo_plc_api.exceptions.device import DevicePasswordProtected, DeviceUnavailable +import pytest + +from homeassistant.components.button import ( + DOMAIN as PLATFORM, + SERVICE_PRESS, + ButtonDeviceClass, +) +from homeassistant.components.devolo_home_network.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity import EntityCategory + +from . import configure_integration +from .mock import MockDevice + + +@pytest.mark.usefixtures("mock_device") +async def test_button_setup(hass: HomeAssistant) -> None: + """Test default setup of the button component.""" + entry = configure_integration(hass) + device_name = entry.title.replace(" ", "_").lower() + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert ( + hass.states.get(f"{PLATFORM}.{device_name}_identify_device_with_a_blinking_led") + is not None + ) + assert hass.states.get(f"{PLATFORM}.{device_name}_start_plc_pairing") is not None + assert hass.states.get(f"{PLATFORM}.{device_name}_restart_device") is not None + assert hass.states.get(f"{PLATFORM}.{device_name}_start_wps") is not None + + await hass.config_entries.async_unload(entry.entry_id) + + +@pytest.mark.freeze_time("2023-01-13 12:00:00+00:00") +async def test_identify_device( + hass: HomeAssistant, mock_device: MockDevice, entity_registry: er.EntityRegistry +) -> None: + """Test start PLC pairing button.""" + entry = configure_integration(hass) + device_name = entry.title.replace(" ", "_").lower() + state_key = f"{PLATFORM}.{device_name}_identify_device_with_a_blinking_led" + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(state_key) + assert state is not None + assert state.state == STATE_UNKNOWN + assert ( + entity_registry.async_get(state_key).entity_category + is EntityCategory.DIAGNOSTIC + ) + + # Emulate button press + await hass.services.async_call( + PLATFORM, + SERVICE_PRESS, + {ATTR_ENTITY_ID: state_key}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(state_key) + assert state.state == "2023-01-13T12:00:00+00:00" + assert mock_device.plcnet.async_identify_device_start.call_count == 1 + + await hass.config_entries.async_unload(entry.entry_id) + + +@pytest.mark.freeze_time("2023-01-13 12:00:00+00:00") +async def test_start_plc_pairing(hass: HomeAssistant, mock_device: MockDevice) -> None: + """Test start PLC pairing button.""" + entry = configure_integration(hass) + device_name = entry.title.replace(" ", "_").lower() + state_key = f"{PLATFORM}.{device_name}_start_plc_pairing" + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(state_key) + assert state is not None + assert state.state == STATE_UNKNOWN + + # Emulate button press + await hass.services.async_call( + PLATFORM, + SERVICE_PRESS, + {ATTR_ENTITY_ID: state_key}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(state_key) + assert state.state == "2023-01-13T12:00:00+00:00" + assert mock_device.plcnet.async_pair_device.call_count == 1 + + await hass.config_entries.async_unload(entry.entry_id) + + +@pytest.mark.freeze_time("2023-01-13 12:00:00+00:00") +async def test_restart( + hass: HomeAssistant, mock_device: MockDevice, entity_registry: er.EntityRegistry +) -> None: + """Test restart button.""" + entry = configure_integration(hass) + device_name = entry.title.replace(" ", "_").lower() + state_key = f"{PLATFORM}.{device_name}_restart_device" + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(state_key) + assert state is not None + assert state.state == STATE_UNKNOWN + assert state.attributes["device_class"] == ButtonDeviceClass.RESTART + assert entity_registry.async_get(state_key).entity_category is EntityCategory.CONFIG + + # Emulate button press + await hass.services.async_call( + PLATFORM, + SERVICE_PRESS, + {ATTR_ENTITY_ID: state_key}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(state_key) + assert state.state == "2023-01-13T12:00:00+00:00" + assert mock_device.device.async_restart.call_count == 1 + + await hass.config_entries.async_unload(entry.entry_id) + + +@pytest.mark.freeze_time("2023-01-13 12:00:00+00:00") +async def test_start_wps(hass: HomeAssistant, mock_device: MockDevice) -> None: + """Test start WPS button.""" + entry = configure_integration(hass) + device_name = entry.title.replace(" ", "_").lower() + state_key = f"{PLATFORM}.{device_name}_start_wps" + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(state_key) + assert state is not None + assert state.state == STATE_UNKNOWN + + # Emulate button press + await hass.services.async_call( + PLATFORM, + SERVICE_PRESS, + {ATTR_ENTITY_ID: state_key}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(state_key) + assert state.state == "2023-01-13T12:00:00+00:00" + assert mock_device.device.async_start_wps.call_count == 1 + + await hass.config_entries.async_unload(entry.entry_id) + + +@pytest.mark.parametrize( + ("name", "trigger_method"), + [ + ["identify_device_with_a_blinking_led", "async_identify_device_start"], + ["start_plc_pairing", "async_pair_device"], + ["restart_device", "async_restart"], + ["start_wps", "async_start_wps"], + ], +) +async def test_device_failure( + hass: HomeAssistant, + mock_device: MockDevice, + name: str, + trigger_method: str, +) -> None: + """Test device failure.""" + entry = configure_integration(hass) + device_name = entry.title.replace(" ", "_").lower() + state_key = f"{PLATFORM}.{device_name}_{name}" + + setattr(mock_device.device, trigger_method, AsyncMock()) + api = getattr(mock_device.device, trigger_method) + api.side_effect = DeviceUnavailable + setattr(mock_device.plcnet, trigger_method, AsyncMock()) + api = getattr(mock_device.plcnet, trigger_method) + api.side_effect = DeviceUnavailable + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + # Emulate button press + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + PLATFORM, + SERVICE_PRESS, + {ATTR_ENTITY_ID: state_key}, + blocking=True, + ) + await hass.async_block_till_done() + + await hass.config_entries.async_unload(entry.entry_id) + + +async def test_auth_failed(hass: HomeAssistant, mock_device: MockDevice) -> None: + """Test setting unautherized triggers the reauth flow.""" + entry = configure_integration(hass) + device_name = entry.title.replace(" ", "_").lower() + state_key = f"{PLATFORM}.{device_name}_start_wps" + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + mock_device.device.async_start_wps.side_effect = DevicePasswordProtected + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + PLATFORM, + SERVICE_PRESS, + {ATTR_ENTITY_ID: state_key}, + blocking=True, + ) + 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 "context" in flow + assert flow["context"]["source"] == SOURCE_REAUTH + assert flow["context"]["entry_id"] == entry.entry_id + + await hass.config_entries.async_unload(entry.entry_id) diff --git a/tests/components/devolo_home_network/test_init.py b/tests/components/devolo_home_network/test_init.py index 038028e66d30..99b6053e1bac 100644 --- a/tests/components/devolo_home_network/test_init.py +++ b/tests/components/devolo_home_network/test_init.py @@ -5,6 +5,7 @@ from devolo_plc_api.exceptions.device import DeviceNotFound import pytest from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR +from homeassistant.components.button import DOMAIN as BUTTON from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER from homeassistant.components.devolo_home_network.const import DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR @@ -83,9 +84,9 @@ async def test_hass_stop(hass: HomeAssistant, mock_device: MockDevice) -> None: @pytest.mark.parametrize( ("device", "expected_platforms"), [ - ["mock_device", (BINARY_SENSOR, DEVICE_TRACKER, SENSOR, SWITCH)], - ["mock_repeater_device", (DEVICE_TRACKER, SENSOR, SWITCH)], - ["mock_nonwifi_device", (BINARY_SENSOR, SENSOR, SWITCH)], + ["mock_device", (BINARY_SENSOR, BUTTON, DEVICE_TRACKER, SENSOR, SWITCH)], + ["mock_repeater_device", (BUTTON, DEVICE_TRACKER, SENSOR, SWITCH)], + ["mock_nonwifi_device", (BINARY_SENSOR, BUTTON, SENSOR, SWITCH)], ], ) async def test_platforms( @@ -93,7 +94,7 @@ async def test_platforms( device: str, expected_platforms: set[str], request: pytest.FixtureRequest, -): +) -> None: """Test platform assembly.""" request.getfixturevalue(device) entry = configure_integration(hass) From 78fa7fc5dace5dd14cd4507199393dc4699506dc Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Sun, 7 May 2023 16:33:15 +0300 Subject: [PATCH 259/822] Bump aiowebostv to 0.3.3 to fix Python 3.11 support (#92736) Bump aiowebostv to 0.3.3 --- homeassistant/components/webostv/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/webostv/manifest.json b/homeassistant/components/webostv/manifest.json index 1eac959c1690..9152739852e2 100644 --- a/homeassistant/components/webostv/manifest.json +++ b/homeassistant/components/webostv/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_push", "loggers": ["aiowebostv"], "quality_scale": "platinum", - "requirements": ["aiowebostv==0.3.2"], + "requirements": ["aiowebostv==0.3.3"], "ssdp": [ { "st": "urn:lge-com:service:webos-second-screen:1" diff --git a/requirements_all.txt b/requirements_all.txt index b3888698320e..4bb38f79fd78 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -300,7 +300,7 @@ aiovlc==0.1.0 aiowatttime==0.1.1 # homeassistant.components.webostv -aiowebostv==0.3.2 +aiowebostv==0.3.3 # homeassistant.components.yandex_transport aioymaps==1.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f9deacd7c723..8675cf79ae39 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -281,7 +281,7 @@ aiovlc==0.1.0 aiowatttime==0.1.1 # homeassistant.components.webostv -aiowebostv==0.3.2 +aiowebostv==0.3.3 # homeassistant.components.yandex_transport aioymaps==1.2.2 From 28efeb9d9e82d0940006f6d96672d660223e15a2 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Sun, 7 May 2023 16:47:02 +0200 Subject: [PATCH 260/822] Update deprecated functions in SIA (#92737) update deprecated functions --- homeassistant/components/sia/__init__.py | 2 +- homeassistant/components/sia/hub.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sia/__init__.py b/homeassistant/components/sia/__init__.py index befa2c5df92e..a59d1f1cdadc 100644 --- a/homeassistant/components/sia/__init__.py +++ b/homeassistant/components/sia/__init__.py @@ -17,7 +17,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN][entry.entry_id] = hub try: if hub.sia_client: - await hub.sia_client.start(reuse_port=True) + await hub.sia_client.async_start(reuse_port=True) except OSError as exc: raise ConfigEntryNotReady( f"SIA Server at port {entry.data[CONF_PORT]} could not start." diff --git a/homeassistant/components/sia/hub.py b/homeassistant/components/sia/hub.py index fb8d20e18307..64ca3832ce0f 100644 --- a/homeassistant/components/sia/hub.py +++ b/homeassistant/components/sia/hub.py @@ -71,7 +71,7 @@ class SIAHub: async def async_shutdown(self, _: Event | None = None) -> None: """Shutdown the SIA server.""" if self.sia_client: - await self.sia_client.stop() + await self.sia_client.async_stop() async def async_create_and_fire_event(self, event: SIAEvent) -> None: """Create a event on HA dispatcher and then on HA's bus, with the data from the SIAEvent. From e753a33fd7a5068b5889751498137e1d612acac8 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Sun, 7 May 2023 10:48:49 -0400 Subject: [PATCH 261/822] Fix regression in Skybell (#92735) --- homeassistant/components/skybell/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/skybell/__init__.py b/homeassistant/components/skybell/__init__.py index b2b102ce155b..ac948408a3f1 100644 --- a/homeassistant/components/skybell/__init__.py +++ b/homeassistant/components/skybell/__init__.py @@ -52,7 +52,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: for coordinator in device_coordinators ] ) - hass.data[DOMAIN][entry.entry_id] = device_coordinators + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = device_coordinators await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True From 54b303dfea32f8a519c024d3cca994f22ae55775 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Sun, 7 May 2023 10:49:34 -0400 Subject: [PATCH 262/822] Cleanup after removing D-Link yaml (#92739) --- homeassistant/components/dlink/entity.py | 7 ++----- tests/components/dlink/conftest.py | 4 +--- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/dlink/entity.py b/homeassistant/components/dlink/entity.py index 33302f7fffac..bfe16abd7809 100644 --- a/homeassistant/components/dlink/entity.py +++ b/homeassistant/components/dlink/entity.py @@ -1,7 +1,7 @@ """Entity representing a D-Link Power Plug device.""" from __future__ import annotations -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_CONNECTIONS from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription @@ -14,6 +14,7 @@ class DLinkEntity(Entity): """Representation of a D-Link Power Plug entity.""" _attr_attribution = ATTRIBUTION + _attr_has_entity_name = True def __init__( self, @@ -24,10 +25,6 @@ class DLinkEntity(Entity): """Initialize a D-Link Power Plug entity.""" self.data = data self.entity_description = description - if config_entry.source == SOURCE_IMPORT: - self._attr_name = config_entry.title - else: - self._attr_has_entity_name = True self._attr_unique_id = f"{config_entry.entry_id}_{description.key}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, config_entry.entry_id)}, diff --git a/tests/components/dlink/conftest.py b/tests/components/dlink/conftest.py index 31e79fe61dff..5618a6645cac 100644 --- a/tests/components/dlink/conftest.py +++ b/tests/components/dlink/conftest.py @@ -8,7 +8,7 @@ import pytest from homeassistant.components import dhcp from homeassistant.components.dlink.const import CONF_USE_LEGACY_PROTOCOL, DOMAIN -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import format_mac from homeassistant.setup import async_setup_component @@ -28,8 +28,6 @@ CONF_DHCP_DATA = { CONF_DATA = CONF_DHCP_DATA | {CONF_HOST: HOST} -CONF_IMPORT_DATA = CONF_DATA | {CONF_NAME: "Smart Plug"} - CONF_DHCP_FLOW = dhcp.DhcpServiceInfo( ip=HOST, macaddress=MAC, From 6fe596b64f4389a50dada6d7b9b4a09a6a1e8adc Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Sun, 7 May 2023 17:11:34 +0200 Subject: [PATCH 263/822] Bump nibe to 2.2.0 (#92740) --- homeassistant/components/nibe_heatpump/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nibe_heatpump/manifest.json b/homeassistant/components/nibe_heatpump/manifest.json index 81c23437bbc0..f57a4511eec7 100644 --- a/homeassistant/components/nibe_heatpump/manifest.json +++ b/homeassistant/components/nibe_heatpump/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/nibe_heatpump", "iot_class": "local_polling", - "requirements": ["nibe==2.1.4"] + "requirements": ["nibe==2.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4bb38f79fd78..cdb780579288 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1196,7 +1196,7 @@ nextcord==2.0.0a8 nextdns==1.4.0 # homeassistant.components.nibe_heatpump -nibe==2.1.4 +nibe==2.2.0 # homeassistant.components.niko_home_control niko-home-control==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8675cf79ae39..89855ffa6d0d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -907,7 +907,7 @@ nextcord==2.0.0a8 nextdns==1.4.0 # homeassistant.components.nibe_heatpump -nibe==2.1.4 +nibe==2.2.0 # homeassistant.components.nfandroidtv notifications-android-tv==0.1.5 From 5b8e72fedaf4edf0a786db44d20a381e7fcbc212 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 7 May 2023 17:11:55 +0200 Subject: [PATCH 264/822] Fix regression in rainbird (#92745) --- homeassistant/components/rainbird/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/rainbird/__init__.py b/homeassistant/components/rainbird/__init__.py index 9cda4ec550f6..14a81f2c6651 100644 --- a/homeassistant/components/rainbird/__init__.py +++ b/homeassistant/components/rainbird/__init__.py @@ -37,7 +37,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) await coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN][entry.entry_id] = coordinator + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) From 266905a9d363ba309d0f3987bef09421329cffe3 Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Sun, 7 May 2023 08:31:25 -0700 Subject: [PATCH 265/822] Fix zwave_js services example data (#92748) --- homeassistant/components/zwave_js/services.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zwave_js/services.yaml b/homeassistant/components/zwave_js/services.yaml index de9d4842ff7d..b9209c6904f7 100644 --- a/homeassistant/components/zwave_js/services.yaml +++ b/homeassistant/components/zwave_js/services.yaml @@ -84,7 +84,7 @@ bulk_set_partial_config_parameters: value: name: Value description: The new value(s) to set for this configuration parameter. Can either be a raw integer value to represent the bulk change or a mapping where the key is the bitmask (either in hex or integer form) and the value is the new value you want to set for that partial parameter. - example: + example: | "0x1": 1 "0x10": 1 "0x20": 1 @@ -287,7 +287,7 @@ invoke_cc_api: parameters: name: Parameters description: A list of parameters to pass to the API method. Refer to the Z-Wave JS Command Class API documentation (https://zwave-js.github.io/node-zwave-js/#/api/CCs/index) for parameters. - example: [1, 1] + example: "[1, 1]" required: true selector: object: From bfb38d2f9ca40a33d66821fa1a9ad2b0bbce7609 Mon Sep 17 00:00:00 2001 From: Brandon Rothweiler Date: Sun, 7 May 2023 11:32:11 -0400 Subject: [PATCH 266/822] Increase timeout to 30 seconds for Mazda integration (#92744) --- homeassistant/components/mazda/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/mazda/__init__.py b/homeassistant/components/mazda/__init__.py index c9adac23186c..bb92496b74f3 100644 --- a/homeassistant/components/mazda/__init__.py +++ b/homeassistant/components/mazda/__init__.py @@ -51,7 +51,7 @@ PLATFORMS = [ ] -async def with_timeout(task, timeout_seconds=10): +async def with_timeout(task, timeout_seconds=30): """Run an async task with a timeout.""" async with async_timeout.timeout(timeout_seconds): return await task From 86e5efd929cf11a659e7e1a72fac5a6825eeecb4 Mon Sep 17 00:00:00 2001 From: Keilin Bickar Date: Sun, 7 May 2023 14:58:14 -0400 Subject: [PATCH 267/822] Bump asyncsleepiq to 1.3.5 (#92759) --- 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 8b6deaa3c7ac..3d757e2328d6 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.4"] + "requirements": ["asyncsleepiq==1.3.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index cdb780579288..acd5c7d82a71 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -383,7 +383,7 @@ async-upnp-client==0.33.1 asyncpysupla==0.0.5 # homeassistant.components.sleepiq -asyncsleepiq==1.3.4 +asyncsleepiq==1.3.5 # homeassistant.components.aten_pe # atenpdu==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 89855ffa6d0d..7cfb3e6c68c1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -340,7 +340,7 @@ arcam-fmj==1.3.0 async-upnp-client==0.33.1 # homeassistant.components.sleepiq -asyncsleepiq==1.3.4 +asyncsleepiq==1.3.5 # homeassistant.components.aurora auroranoaa==0.0.3 From d14af51f562a2856b828cd0b67b43ca55a1a525f Mon Sep 17 00:00:00 2001 From: Stephan Uhle Date: Sun, 7 May 2023 21:24:42 +0200 Subject: [PATCH 268/822] Add debug logs for EDL21 (#92763) --- homeassistant/components/edl21/sensor.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/homeassistant/components/edl21/sensor.py b/homeassistant/components/edl21/sensor.py index df9606475ffa..e526f951f1c6 100644 --- a/homeassistant/components/edl21/sensor.py +++ b/homeassistant/components/edl21/sensor.py @@ -344,6 +344,11 @@ class EDL21: self._name = config.get(CONF_NAME) self._proto = SmlProtocol(config[CONF_SERIAL_PORT]) self._proto.add_listener(self.event, ["SmlGetListResponse"]) + LOGGER.debug( + "Initialized EDL21 for %s on %s", + config.get(CONF_NAME), + config[CONF_SERIAL_PORT], + ) async def connect(self) -> None: """Connect to an EDL21 reader.""" @@ -352,6 +357,7 @@ class EDL21: def event(self, message_body) -> None: """Handle events from pysml.""" assert isinstance(message_body, SmlGetListResponse) + LOGGER.debug("Received sml message for %s: %s", self._name, message_body) electricity_id = None for telegram in message_body.get("valList", []): @@ -360,6 +366,7 @@ class EDL21: break if electricity_id is None: + LOGGER.debug("No electricity id found in sml message for %s", self._name) return electricity_id = electricity_id.replace(" ", "") From 2e65b77b2b5c17919939481f327963abdfdc53f0 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 7 May 2023 22:03:37 +0200 Subject: [PATCH 269/822] Bump pytrafikverket to 0.3.3 (#92761) --- homeassistant/components/trafikverket_ferry/manifest.json | 2 +- homeassistant/components/trafikverket_train/manifest.json | 2 +- .../components/trafikverket_weatherstation/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/trafikverket_ferry/manifest.json b/homeassistant/components/trafikverket_ferry/manifest.json index 2221038caaeb..5822566505b7 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.1"] + "requirements": ["pytrafikverket==0.3.3"] } diff --git a/homeassistant/components/trafikverket_train/manifest.json b/homeassistant/components/trafikverket_train/manifest.json index 7af7c6df761e..7b8369cec17e 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.1"] + "requirements": ["pytrafikverket==0.3.3"] } diff --git a/homeassistant/components/trafikverket_weatherstation/manifest.json b/homeassistant/components/trafikverket_weatherstation/manifest.json index 3d584e809985..014637b99f62 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.1"] + "requirements": ["pytrafikverket==0.3.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index acd5c7d82a71..35472a4037de 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2146,7 +2146,7 @@ pytradfri[async]==9.0.1 # homeassistant.components.trafikverket_ferry # homeassistant.components.trafikverket_train # homeassistant.components.trafikverket_weatherstation -pytrafikverket==0.3.1 +pytrafikverket==0.3.3 # homeassistant.components.usb pyudev==0.23.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7cfb3e6c68c1..3494c3b1672d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1554,7 +1554,7 @@ pytradfri[async]==9.0.1 # homeassistant.components.trafikverket_ferry # homeassistant.components.trafikverket_train # homeassistant.components.trafikverket_weatherstation -pytrafikverket==0.3.1 +pytrafikverket==0.3.3 # homeassistant.components.usb pyudev==0.23.2 From 6ad4e13b38cb98fe6502bcbf440f498692f1ac8a Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 8 May 2023 10:19:37 +0200 Subject: [PATCH 270/822] Manual trigger entity and refactor command_line switch (#91506) * TriggerEntity to CoordinatorTriggerEntity * _render_templates * split manual vs coordinator * name * ManualTriggerEntity * value * Remove ManualTriggerEntity * ManualTriggerEntity * process_manual_data * Add test * imports * Move ManualTriggerEntity * cmd_line switch * Review comments * Fix templating * Review comments * Remove unneeded logging --- .../components/command_line/switch.py | 39 +++++++------- homeassistant/helpers/template_entity.py | 37 ++++++++++++- tests/components/command_line/test_switch.py | 54 +++++++++++++++++++ .../template/test_manual_trigger_entity.py | 40 ++++++++++++++ 4 files changed, 147 insertions(+), 23 deletions(-) create mode 100644 tests/components/template/test_manual_trigger_entity.py diff --git a/homeassistant/components/command_line/switch.py b/homeassistant/components/command_line/switch.py index 3c344891fbad..bfb45f5b5c42 100644 --- a/homeassistant/components/command_line/switch.py +++ b/homeassistant/components/command_line/switch.py @@ -16,7 +16,9 @@ from homeassistant.const import ( CONF_COMMAND_ON, CONF_COMMAND_STATE, CONF_FRIENDLY_NAME, + CONF_ICON, CONF_ICON_TEMPLATE, + CONF_NAME, CONF_SWITCHES, CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, @@ -26,6 +28,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.reload import async_setup_reload_service from homeassistant.helpers.template import Template +from homeassistant.helpers.template_entity import ManualTriggerEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, PLATFORMS @@ -65,26 +68,26 @@ async def async_setup_platform( switches = [] for object_id, device_config in devices.items(): + trigger_entity_config = { + CONF_UNIQUE_ID: device_config.get(CONF_UNIQUE_ID), + CONF_NAME: Template(device_config.get(CONF_FRIENDLY_NAME, object_id), hass), + CONF_ICON: device_config.get(CONF_ICON_TEMPLATE), + } + value_template: Template | None = device_config.get(CONF_VALUE_TEMPLATE) if value_template is not None: value_template.hass = hass - icon_template: Template | None = device_config.get(CONF_ICON_TEMPLATE) - if icon_template is not None: - icon_template.hass = hass - switches.append( CommandSwitch( + trigger_entity_config, object_id, - device_config.get(CONF_FRIENDLY_NAME, object_id), device_config[CONF_COMMAND_ON], device_config[CONF_COMMAND_OFF], device_config.get(CONF_COMMAND_STATE), - icon_template, value_template, device_config[CONF_COMMAND_TIMEOUT], - device_config.get(CONF_UNIQUE_ID), ) ) @@ -95,32 +98,28 @@ async def async_setup_platform( async_add_entities(switches) -class CommandSwitch(SwitchEntity): +class CommandSwitch(ManualTriggerEntity, SwitchEntity): """Representation a switch that can be toggled using shell commands.""" def __init__( self, + config: ConfigType, object_id: str, - friendly_name: str, command_on: str, command_off: str, command_state: str | None, - icon_template: Template | None, value_template: Template | None, timeout: int, - unique_id: str | None, ) -> None: """Initialize the switch.""" + super().__init__(self.hass, config) self.entity_id = ENTITY_ID_FORMAT.format(object_id) - self._attr_name = friendly_name self._attr_is_on = False self._command_on = command_on self._command_off = command_off self._command_state = command_state - self._icon_template = icon_template self._value_template = value_template self._timeout = timeout - self._attr_unique_id = unique_id self._attr_should_poll = bool(command_state) async def _switch(self, command: str) -> bool: @@ -169,17 +168,15 @@ class CommandSwitch(SwitchEntity): """Update device state.""" if self._command_state: payload = str(await self.hass.async_add_executor_job(self._query_state)) - if self._icon_template: - self._attr_icon = ( - self._icon_template.async_render_with_possible_json_value(payload) - ) + value = None if self._value_template: - payload = self._value_template.async_render_with_possible_json_value( + value = self._value_template.async_render_with_possible_json_value( payload, None ) self._attr_is_on = None - if payload: - self._attr_is_on = payload.lower() == "true" + if payload or value: + self._attr_is_on = (value or payload).lower() == "true" + self._process_manual_data(payload) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" diff --git a/homeassistant/helpers/template_entity.py b/homeassistant/helpers/template_entity.py index b6b39e9c32fa..c4eb8a1343d3 100644 --- a/homeassistant/helpers/template_entity.py +++ b/homeassistant/helpers/template_entity.py @@ -29,6 +29,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, CoreState, Event, HomeAssistant, State, callback from homeassistant.exceptions import TemplateError +from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads from . import config_validation as cv from .entity import Entity @@ -487,9 +488,8 @@ class TriggerBaseEntity(Entity): CONF_NAME, CONF_PICTURE, ): - if itm not in config: + if itm not in config or config[itm] is None: continue - if config[itm].is_static: self._static_rendered[itm] = config[itm].template else: @@ -597,3 +597,36 @@ class TriggerBaseEntity(Entity): "Error rendering %s template for %s: %s", key, self.entity_id, err ) self._rendered = self._static_rendered + + +class ManualTriggerEntity(TriggerBaseEntity): + """Template entity based on manual trigger data.""" + + def __init__( + self, + hass: HomeAssistant, + config: dict, + ) -> None: + """Initialize the entity.""" + TriggerBaseEntity.__init__(self, hass, config) + + @callback + def _process_manual_data(self, value: str | None = None) -> None: + """Process new data manually. + + Implementing class should call this last in update method to render templates. + Ex: self._process_manual_data(payload) + """ + + self.async_write_ha_state() + this = None + if state := self.hass.states.get(self.entity_id): + this = state.as_dict() + + run_variables: dict[str, Any] = {"value": value} + # Silently try if variable is a json and store result in `value_json` if it is. + with contextlib.suppress(*JSON_DECODE_EXCEPTIONS): + run_variables["value_json"] = json_loads(run_variables["value"]) + variables = {"this": this, **(run_variables or {})} + + self._render_templates(variables) diff --git a/tests/components/command_line/test_switch.py b/tests/components/command_line/test_switch.py index bc8eadcb22f3..69e78379afe6 100644 --- a/tests/components/command_line/test_switch.py +++ b/tests/components/command_line/test_switch.py @@ -440,3 +440,57 @@ async def test_command_failure( DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: "switch.test"}, blocking=True ) assert "return code 33" in caplog.text + + +async def test_templating(hass: HomeAssistant) -> None: + """Test with templating.""" + with tempfile.TemporaryDirectory() as tempdirname: + path = os.path.join(tempdirname, "switch_status") + await setup_test_entity( + hass, + { + "test": { + "command_state": f"cat {path}", + "command_on": f"echo 1 > {path}", + "command_off": f"echo 0 > {path}", + "value_template": '{{ value=="1" }}', + "icon_template": ( + '{% if this.state=="on" %} mdi:on {% else %} mdi:off {% endif %}' + ), + }, + "test2": { + "command_state": f"cat {path}", + "command_on": f"echo 1 > {path}", + "command_off": f"echo 0 > {path}", + "value_template": '{{ value=="1" }}', + "icon_template": ( + '{% if states("switch.test2")=="on" %} mdi:on {% else %} mdi:off {% endif %}' + ), + }, + }, + ) + + entity_state = hass.states.get("switch.test") + entity_state2 = hass.states.get("switch.test2") + assert entity_state.state == STATE_OFF + assert entity_state2.state == STATE_OFF + + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.test"}, + blocking=True, + ) + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.test2"}, + blocking=True, + ) + + entity_state = hass.states.get("switch.test") + entity_state2 = hass.states.get("switch.test2") + assert entity_state.state == STATE_ON + assert entity_state.attributes.get("icon") == "mdi:on" + assert entity_state2.state == STATE_ON + assert entity_state2.attributes.get("icon") == "mdi:on" diff --git a/tests/components/template/test_manual_trigger_entity.py b/tests/components/template/test_manual_trigger_entity.py new file mode 100644 index 000000000000..19210645a0fb --- /dev/null +++ b/tests/components/template/test_manual_trigger_entity.py @@ -0,0 +1,40 @@ +"""Test template trigger entity.""" + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import template +from homeassistant.helpers.template_entity import ManualTriggerEntity + + +async def test_template_entity_requires_hass_set(hass: HomeAssistant) -> None: + """Test manual trigger template entity.""" + config = { + "name": template.Template("test_entity", hass), + "icon": template.Template( + '{% if value=="on" %} mdi:on {% else %} mdi:off {% endif %}', hass + ), + "picture": template.Template( + '{% if value=="on" %} /local/picture_on {% else %} /local/picture_off {% endif %}', + hass, + ), + } + + entity = ManualTriggerEntity(hass, config) + entity.entity_id = "test.entity" + hass.states.async_set("test.entity", "on") + await entity.async_added_to_hass() + + entity._process_manual_data("on") + await hass.async_block_till_done() + + assert entity.name == "test_entity" + assert entity.icon == "mdi:on" + assert entity.entity_picture == "/local/picture_on" + + hass.states.async_set("test.entity", "off") + await entity.async_added_to_hass() + entity._process_manual_data("off") + await hass.async_block_till_done() + + assert entity.name == "test_entity" + assert entity.icon == "mdi:off" + assert entity.entity_picture == "/local/picture_off" From 043812bfc325423bae60ba54a18a5e54013c49eb Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 8 May 2023 10:51:47 +0200 Subject: [PATCH 271/822] Bump hass-nabucasa to 0.67.1 (#92484) * Bump hass-nabucasa to 0.67.1 * Fix typing --- homeassistant/components/cloud/manifest.json | 2 +- homeassistant/components/http/auth.py | 4 ++-- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 45d540e9780a..d352b7226f0e 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.66.2"] + "requirements": ["hass-nabucasa==0.67.1"] } diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index f2cfe0674047..21627ef7e4f3 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -105,11 +105,11 @@ def async_user_not_allowed_do_auth( return "User is local only" try: - remote = ip_address(request.remote) # type: ignore[arg-type] + remote_address = ip_address(request.remote) # type: ignore[arg-type] except ValueError: return "Invalid remote IP" - if is_local(remote): + if is_local(remote_address): return None return "User cannot authenticate remotely" diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 7d4b54f98a2b..122e82c4bf8b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -22,7 +22,7 @@ cryptography==40.0.2 dbus-fast==1.86.0 fnv-hash-fast==0.3.1 ha-av==10.0.0 -hass-nabucasa==0.66.2 +hass-nabucasa==0.67.1 hassil==1.0.6 home-assistant-bluetooth==1.10.0 home-assistant-frontend==20230503.3 diff --git a/requirements_all.txt b/requirements_all.txt index 35472a4037de..e6725db7932f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -872,7 +872,7 @@ ha-philipsjs==3.0.0 habitipy==0.2.0 # homeassistant.components.cloud -hass-nabucasa==0.66.2 +hass-nabucasa==0.67.1 # homeassistant.components.splunk hass_splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3494c3b1672d..23f9df1b847a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -676,7 +676,7 @@ ha-philipsjs==3.0.0 habitipy==0.2.0 # homeassistant.components.cloud -hass-nabucasa==0.66.2 +hass-nabucasa==0.67.1 # homeassistant.components.conversation hassil==1.0.6 From bafb01246a5a0e147625371d4799510a7049c47b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Mon, 8 May 2023 15:16:16 +0200 Subject: [PATCH 272/822] Update aioairzone to v0.5.3 (#92780) --- 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 08d7fb1aced9..a99d0ffd793d 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.5.2"] + "requirements": ["aioairzone==0.5.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index e6725db7932f..cd702ae9e238 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -116,7 +116,7 @@ aio_georss_gdacs==0.8 aioairq==0.2.4 # homeassistant.components.airzone -aioairzone==0.5.2 +aioairzone==0.5.3 # homeassistant.components.ambient_station aioambient==2023.04.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 23f9df1b847a..1f9247df3f32 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -106,7 +106,7 @@ aio_georss_gdacs==0.8 aioairq==0.2.4 # homeassistant.components.airzone -aioairzone==0.5.2 +aioairzone==0.5.3 # homeassistant.components.ambient_station aioambient==2023.04.0 From 689c6fbef7424c6723dc5dafa2b3666f00042f6e Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 8 May 2023 15:37:25 +0200 Subject: [PATCH 273/822] Merge pending MQTT subscribes to a single call to the paho client (#92172) * Merge mqtt subscribes in one call * Cleanup * cleanup, log outside of lock * Remove function wrapper * Add test that we bundle subscriptions --- homeassistant/components/mqtt/client.py | 52 ++++++------------ tests/components/mqtt/test_common.py | 9 ++++ tests/components/mqtt/test_discovery.py | 8 +-- tests/components/mqtt/test_init.py | 71 ++++++++++++++++++------- 4 files changed, 82 insertions(+), 58 deletions(-) diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index cd73ee8efb65..ce538a6af13f 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -644,7 +644,6 @@ class MQTT: async def _async_perform_subscriptions(self) -> None: """Perform MQTT client subscriptions.""" - subscriptions: dict[str, int] # Section 3.3.1.3 in the specification: # http://docs.oasis-open.org/mqtt/mqtt/v3.1.1/os/mqtt-v3.1.1-os.html # When sending a PUBLISH Packet to a Client the Server MUST @@ -657,36 +656,26 @@ class MQTT: # Since we do not know if a published value is retained we need to # (re)subscribe, to ensure retained messages are replayed - def _process_client_subscriptions() -> list[tuple[int, int]]: - """Initiate all subscriptions on the MQTT client and return the results.""" - subscribe_result_list = [] - for topic, qos in subscriptions.items(): - result, mid = self._mqttc.subscribe(topic, qos) - subscribe_result_list.append((result, mid)) - _LOGGER.debug("Subscribing to %s, mid: %s, qos: %s", topic, mid, qos) - return subscribe_result_list + if not self._pending_subscriptions: + return - subscriptions = self._pending_subscriptions + subscriptions: dict[str, int] = self._pending_subscriptions self._pending_subscriptions = {} async with self._paho_lock: - results = await self.hass.async_add_executor_job( - _process_client_subscriptions + subscription_list = list(subscriptions.items()) + result, mid = await self.hass.async_add_executor_job( + self._mqttc.subscribe, subscription_list ) + + for topic, qos in subscriptions.items(): + _LOGGER.debug("Subscribing to %s, mid: %s, qos: %s", topic, mid, qos) self._last_subscribe = time.time() - tasks: list[Coroutine[Any, Any, None]] = [] - errors: list[int] = [] - for result, mid in results: - if result == 0: - tasks.append(self._wait_for_mid(mid)) - else: - errors.append(result) - - if tasks: - await asyncio.gather(*tasks) - if errors: - _raise_on_errors(errors) + if result == 0: + await self._wait_for_mid(mid) + else: + _raise_on_error(result) def _mqtt_on_connect( self, @@ -904,22 +893,13 @@ class MQTT: ) -def _raise_on_errors(result_codes: Iterable[int]) -> None: +def _raise_on_error(result_code: int) -> None: """Raise error if error result.""" # pylint: disable-next=import-outside-toplevel import paho.mqtt.client as mqtt - if messages := [ - mqtt.error_string(result_code) - for result_code in result_codes - if result_code != 0 - ]: - raise HomeAssistantError(f"Error talking to MQTT: {', '.join(messages)}") - - -def _raise_on_error(result_code: int) -> None: - """Raise error if error result.""" - _raise_on_errors((result_code,)) + if result_code and (message := mqtt.error_string(result_code)): + raise HomeAssistantError(f"Error talking to MQTT: {message}") def _matcher_for_topic(subscription: str) -> Any: diff --git a/tests/components/mqtt/test_common.py b/tests/components/mqtt/test_common.py index 620f1e95c233..f9df3450c8d5 100644 --- a/tests/components/mqtt/test_common.py +++ b/tests/components/mqtt/test_common.py @@ -73,6 +73,15 @@ _StateDataType = list[tuple[_MqttMessageType, str | None, _AttributesType | None MQTT_YAML_SCHEMA = vol.Schema({mqtt.DOMAIN: PLATFORM_CONFIG_SCHEMA_BASE}) +def help_all_subscribe_calls(mqtt_client_mock: MqttMockPahoClient) -> list[Any]: + """Test of a call.""" + all_calls = [] + for calls in mqtt_client_mock.subscribe.mock_calls: + for call in calls[1]: + all_calls.extend(call) + return all_calls + + def help_test_validate_platform_config( hass: HomeAssistant, config: ConfigType ) -> ConfigType | None: diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index 22cf9ecceed0..800809f15ad5 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -28,7 +28,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.service_info.mqtt import MqttServiceInfo from homeassistant.setup import async_setup_component -from .test_common import help_test_unload_config_entry +from .test_common import help_all_subscribe_calls, help_test_unload_config_entry from tests.common import ( MockConfigEntry, @@ -1396,7 +1396,7 @@ async def test_mqtt_integration_discovery_subscribe_unsubscribe( await hass.async_block_till_done() await hass.async_block_till_done() - mqtt_client_mock.subscribe.assert_any_call("comp/discovery/#", 0) + assert ("comp/discovery/#", 0) in help_all_subscribe_calls(mqtt_client_mock) assert not mqtt_client_mock.unsubscribe.called class TestFlow(config_entries.ConfigFlow): @@ -1407,7 +1407,7 @@ async def test_mqtt_integration_discovery_subscribe_unsubscribe( return self.async_abort(reason="already_configured") with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}): - mqtt_client_mock.subscribe.assert_any_call("comp/discovery/#", 0) + assert ("comp/discovery/#", 0) in help_all_subscribe_calls(mqtt_client_mock) assert not mqtt_client_mock.unsubscribe.called async_fire_mqtt_message(hass, "comp/discovery/bla/config", "") @@ -1443,7 +1443,7 @@ async def test_mqtt_discovery_unsubscribe_once( await hass.async_block_till_done() await hass.async_block_till_done() - mqtt_client_mock.subscribe.assert_any_call("comp/discovery/#", 0) + assert ("comp/discovery/#", 0) in help_all_subscribe_calls(mqtt_client_mock) assert not mqtt_client_mock.unsubscribe.called class TestFlow(config_entries.ConfigFlow): diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 498365de4a38..dec24128cc6f 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -36,7 +36,7 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow -from .test_common import help_test_validate_platform_config +from .test_common import help_all_subscribe_calls, help_test_validate_platform_config from tests.common import ( MockConfigEntry, @@ -1342,16 +1342,16 @@ async def test_unsubscribe_race( # We allow either calls [subscribe, unsubscribe, subscribe], [subscribe, subscribe] or # when both subscriptions were combined [subscribe] expected_calls_1 = [ - call.subscribe("test/state", 0), + call.subscribe([("test/state", 0)]), call.unsubscribe("test/state"), - call.subscribe("test/state", 0), + call.subscribe([("test/state", 0)]), ] expected_calls_2 = [ - call.subscribe("test/state", 0), - call.subscribe("test/state", 0), + call.subscribe([("test/state", 0)]), + call.subscribe([("test/state", 0)]), ] expected_calls_3 = [ - call.subscribe("test/state", 0), + call.subscribe([("test/state", 0)]), ] assert mqtt_client_mock.mock_calls in ( expected_calls_1, @@ -1418,7 +1418,7 @@ async def test_restore_all_active_subscriptions_on_reconnect( # the subscribtion with the highest QoS should survive expected = [ - call("test/state", 2), + call([("test/state", 2)]), ] assert mqtt_client_mock.subscribe.mock_calls == expected @@ -1432,7 +1432,7 @@ async def test_restore_all_active_subscriptions_on_reconnect( async_fire_time_changed(hass, utcnow() + timedelta(seconds=3)) # cooldown await hass.async_block_till_done() - expected.append(call("test/state", 1)) + expected.append(call([("test/state", 1)])) assert mqtt_client_mock.subscribe.mock_calls == expected async_fire_time_changed(hass, utcnow() + timedelta(seconds=3)) # cooldown @@ -1463,9 +1463,7 @@ async def test_subscribed_at_highest_qos( await hass.async_block_till_done() async_fire_time_changed(hass, utcnow() + timedelta(seconds=5)) # cooldown await hass.async_block_till_done() - assert mqtt_client_mock.subscribe.mock_calls == [ - call("test/state", 0), - ] + assert ("test/state", 0) in help_all_subscribe_calls(mqtt_client_mock) mqtt_client_mock.reset_mock() async_fire_time_changed(hass, utcnow() + timedelta(seconds=5)) # cooldown await hass.async_block_till_done() @@ -1477,9 +1475,7 @@ async def test_subscribed_at_highest_qos( async_fire_time_changed(hass, utcnow() + timedelta(seconds=5)) # cooldown await hass.async_block_till_done() # the subscribtion with the highest QoS should survive - assert mqtt_client_mock.subscribe.mock_calls == [ - call("test/state", 2), - ] + assert help_all_subscribe_calls(mqtt_client_mock) == [("test/state", 2)] async def test_reload_entry_with_restored_subscriptions( @@ -2224,10 +2220,49 @@ async def test_mqtt_subscribes_topics_on_connect( assert mqtt_client_mock.disconnect.call_count == 0 - assert mqtt_client_mock.subscribe.call_count == 3 - mqtt_client_mock.subscribe.assert_any_call("topic/test", 0) - mqtt_client_mock.subscribe.assert_any_call("home/sensor", 2) - mqtt_client_mock.subscribe.assert_any_call("still/pending", 1) + subscribe_calls = help_all_subscribe_calls(mqtt_client_mock) + assert len(subscribe_calls) == 3 + assert ("topic/test", 0) in subscribe_calls + assert ("home/sensor", 2) in subscribe_calls + assert ("still/pending", 1) in subscribe_calls + + +@pytest.mark.parametrize( + "mqtt_config_entry_data", + [ + { + mqtt.CONF_BROKER: "mock-broker", + mqtt.CONF_BIRTH_MESSAGE: {}, + mqtt.CONF_DISCOVERY: False, + } + ], +) +@patch("homeassistant.components.mqtt.client.SUBSCRIBE_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 0.0) +async def test_mqtt_subscribes_in_single_call( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, + mqtt_mock_entry: MqttMockHAClientGenerator, + record_calls: MessageCallbackType, +) -> None: + """Test bundled client subscription to topic.""" + mqtt_mock = await mqtt_mock_entry() + # Fake that the client is connected + mqtt_mock().connected = True + + mqtt_client_mock.subscribe.reset_mock() + await mqtt.async_subscribe(hass, "topic/test", record_calls) + await mqtt.async_subscribe(hass, "home/sensor", record_calls) + await hass.async_block_till_done() + # Make sure the debouncer finishes + await asyncio.sleep(0.2) + + assert mqtt_client_mock.subscribe.call_count == 1 + # Assert we have a single subscription call with both subscriptions + assert mqtt_client_mock.subscribe.mock_calls[0][1][0] in [ + [("topic/test", 0), ("home/sensor", 0)], + [("home/sensor", 0), ("topic/test", 0)], + ] async def test_default_entry_setting_are_applied( From cdc4b315e52b1e411686bd439c6c553f8e649073 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 8 May 2023 11:56:06 -0500 Subject: [PATCH 274/822] Bump aioesphomeapi to 3.7.4 to fix proxied BLE connections not retrying right away on error (#92741) --- 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 ff78996f3aa6..0e9715038f06 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -15,7 +15,7 @@ "iot_class": "local_push", "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ - "aioesphomeapi==13.7.3", + "aioesphomeapi==13.7.4", "bluetooth-data-tools==0.4.0", "esphome-dashboard-api==1.2.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index cd702ae9e238..beb6fb884a06 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -156,7 +156,7 @@ aioecowitt==2023.01.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==13.7.3 +aioesphomeapi==13.7.4 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1f9247df3f32..d1fdd2e72e76 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -146,7 +146,7 @@ aioecowitt==2023.01.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==13.7.3 +aioesphomeapi==13.7.4 # homeassistant.components.flo aioflo==2021.11.0 From 0bda8695531b7916555477b76c51f77c306d2fee Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 8 May 2023 22:06:11 +0200 Subject: [PATCH 275/822] Lock entity options (#88139) --- homeassistant/components/lock/__init__.py | 47 ++++++++- tests/components/lock/test_init.py | 98 +++++++++++++++++++ .../custom_components/test/lock.py | 5 + 3 files changed, 146 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/lock/__init__.py b/homeassistant/components/lock/__init__.py index c68d99bfb22a..8cbce69dc7c1 100644 --- a/homeassistant/components/lock/__init__.py +++ b/homeassistant/components/lock/__init__.py @@ -24,7 +24,7 @@ from homeassistant.const import ( STATE_UNLOCKED, STATE_UNLOCKING, ) -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, @@ -39,6 +39,7 @@ from homeassistant.helpers.typing import ConfigType, StateType _LOGGER = logging.getLogger(__name__) ATTR_CHANGED_BY = "changed_by" +CONF_DEFAULT_CODE = "default_code" DOMAIN = "lock" SCAN_INTERVAL = timedelta(seconds=30) @@ -88,7 +89,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def _async_lock(entity: LockEntity, service_call: ServiceCall) -> None: """Lock the lock.""" - code: str = service_call.data.get(ATTR_CODE, "") + code: str = service_call.data.get( + ATTR_CODE, entity._lock_option_default_code # pylint: disable=protected-access + ) if entity.code_format_cmp and not entity.code_format_cmp.match(code): raise ValueError( f"Code '{code}' for locking {entity.entity_id} doesn't match pattern {entity.code_format}" @@ -98,7 +101,9 @@ async def _async_lock(entity: LockEntity, service_call: ServiceCall) -> None: async def _async_unlock(entity: LockEntity, service_call: ServiceCall) -> None: """Unlock the lock.""" - code: str = service_call.data.get(ATTR_CODE, "") + code: str = service_call.data.get( + ATTR_CODE, entity._lock_option_default_code # pylint: disable=protected-access + ) if entity.code_format_cmp and not entity.code_format_cmp.match(code): raise ValueError( f"Code '{code}' for unlocking {entity.entity_id} doesn't match pattern {entity.code_format}" @@ -108,7 +113,9 @@ async def _async_unlock(entity: LockEntity, service_call: ServiceCall) -> None: async def _async_open(entity: LockEntity, service_call: ServiceCall) -> None: """Open the door latch.""" - code: str = service_call.data.get(ATTR_CODE, "") + code: str = service_call.data.get( + ATTR_CODE, entity._lock_option_default_code # pylint: disable=protected-access + ) if entity.code_format_cmp and not entity.code_format_cmp.match(code): raise ValueError( f"Code '{code}' for opening {entity.entity_id} doesn't match pattern {entity.code_format}" @@ -145,6 +152,7 @@ class LockEntity(Entity): _attr_is_jammed: bool | None = None _attr_state: None = None _attr_supported_features: LockEntityFeature = LockEntityFeature(0) + _lock_option_default_code: str = "" __code_format_cmp: re.Pattern[str] | None = None @property @@ -243,3 +251,34 @@ class LockEntity(Entity): def supported_features(self) -> LockEntityFeature: """Return the list of supported features.""" return self._attr_supported_features + + async def async_internal_added_to_hass(self) -> None: + """Call when the sensor entity is added to hass.""" + await super().async_internal_added_to_hass() + if not self.registry_entry: + return + self._async_read_entity_options() + + @callback + def async_registry_entry_updated(self) -> None: + """Run when the entity registry entry has been updated.""" + self._async_read_entity_options() + + @callback + def _async_read_entity_options(self) -> None: + """Read entity options from entity registry. + + Called when the entity registry entry has been updated and before the lock is + added to the state machine. + """ + assert self.registry_entry + if (lock_options := self.registry_entry.options.get(DOMAIN)) and ( + custom_default_lock_code := lock_options.get(CONF_DEFAULT_CODE) + ): + if self.code_format_cmp and self.code_format_cmp.match( + custom_default_lock_code + ): + self._lock_option_default_code = custom_default_lock_code + return + + self._lock_option_default_code = "" diff --git a/tests/components/lock/test_init.py b/tests/components/lock/test_init.py index 4943d63c6ed9..0d33881c46cc 100644 --- a/tests/components/lock/test_init.py +++ b/tests/components/lock/test_init.py @@ -8,6 +8,7 @@ import pytest from homeassistant.components.lock import ( ATTR_CODE, + CONF_DEFAULT_CODE, DOMAIN, SERVICE_LOCK, SERVICE_OPEN, @@ -24,6 +25,10 @@ from homeassistant.components.lock import ( _async_unlock, ) from homeassistant.core import HomeAssistant, ServiceCall +import homeassistant.helpers.entity_registry as er +from homeassistant.setup import async_setup_component + +from tests.testing_config.custom_components.test.lock import MockLock class MockLockEntity(LockEntity): @@ -32,6 +37,7 @@ class MockLockEntity(LockEntity): def __init__( self, code_format: str | None = None, + lock_option_default_code: str = "", supported_features: LockEntityFeature = LockEntityFeature(0), ) -> None: """Initialize mock lock entity.""" @@ -39,6 +45,7 @@ class MockLockEntity(LockEntity): self.calls_open = MagicMock() if code_format is not None: self._attr_code_format = code_format + self._lock_option_default_code = lock_option_default_code async def async_lock(self, **kwargs: Any) -> None: """Lock the lock.""" @@ -95,6 +102,80 @@ async def test_lock_states(hass: HomeAssistant) -> None: assert not lock.is_locked +async def test_set_default_code_option( + hass: HomeAssistant, + enable_custom_integrations: None, +) -> None: + """Test default code stored in the registry.""" + entity_registry = er.async_get(hass) + + entry = entity_registry.async_get_or_create("lock", "test", "very_unique") + await hass.async_block_till_done() + + platform = getattr(hass.components, "test.lock") + platform.init(empty=True) + platform.ENTITIES["lock1"] = platform.MockLock( + name="Test", + code_format=r"^\d{4}$", + supported_features=LockEntityFeature.OPEN, + unique_id="very_unique", + ) + + assert await async_setup_component(hass, "lock", {"lock": {"platform": "test"}}) + await hass.async_block_till_done() + + entity0: MockLock = platform.ENTITIES["lock1"] + entity_registry.async_update_entity_options( + entry.entity_id, "lock", {CONF_DEFAULT_CODE: "1234"} + ) + await hass.async_block_till_done() + + assert entity0._lock_option_default_code == "1234" + + +async def test_default_code_option_update( + hass: HomeAssistant, + enable_custom_integrations: None, +) -> None: + """Test default code stored in the registry is updated.""" + entity_registry = er.async_get(hass) + + entry = entity_registry.async_get_or_create("lock", "test", "very_unique") + await hass.async_block_till_done() + + platform = getattr(hass.components, "test.lock") + platform.init(empty=True) + + # Pre-register entities + entry = entity_registry.async_get_or_create("lock", "test", "very_unique") + entity_registry.async_update_entity_options( + entry.entity_id, + "lock", + { + "default_code": "5432", + }, + ) + platform.ENTITIES["lock1"] = platform.MockLock( + name="Test", + code_format=r"^\d{4}$", + supported_features=LockEntityFeature.OPEN, + unique_id="very_unique", + ) + + assert await async_setup_component(hass, "lock", {"lock": {"platform": "test"}}) + await hass.async_block_till_done() + + entity0: MockLock = platform.ENTITIES["lock1"] + assert entity0._lock_option_default_code == "5432" + + entity_registry.async_update_entity_options( + entry.entity_id, "lock", {CONF_DEFAULT_CODE: "1234"} + ) + await hass.async_block_till_done() + + assert entity0._lock_option_default_code == "1234" + + async def test_lock_open_with_code(hass: HomeAssistant) -> None: """Test lock entity with open service.""" lock = MockLockEntity( @@ -150,3 +231,20 @@ async def test_lock_unlock_with_code(hass: HomeAssistant) -> None: ) await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: "1234"})) assert not lock.is_locked + + +async def test_lock_with_default_code(hass: HomeAssistant) -> None: + """Test lock entity with default code.""" + lock = MockLockEntity( + code_format=r"^\d{4}$", + supported_features=LockEntityFeature.OPEN, + lock_option_default_code="1234", + ) + lock.hass = hass + + assert lock.state_attributes == {"code_format": r"^\d{4}$"} + assert lock._lock_option_default_code == "1234" + + await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {})) + await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {})) + await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {})) diff --git a/tests/testing_config/custom_components/test/lock.py b/tests/testing_config/custom_components/test/lock.py index f98cc3fa671c..b48e8b1fad9e 100644 --- a/tests/testing_config/custom_components/test/lock.py +++ b/tests/testing_config/custom_components/test/lock.py @@ -43,6 +43,11 @@ async def async_setup_platform( class MockLock(MockEntity, LockEntity): """Mock Lock class.""" + @property + def code_format(self) -> str | None: + """Return code format.""" + return self._handle("code_format") + @property def is_locked(self): """Return true if the lock is locked.""" From 04c298b772bba02ca7b4ffd5cb2220859e471e9a Mon Sep 17 00:00:00 2001 From: jjlawren Date: Tue, 9 May 2023 00:58:56 -0500 Subject: [PATCH 276/822] Bump sonos-websocket to 0.1.1 (#92834) --- homeassistant/components/sonos/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index 4a05053940c3..087c636f1ed8 100644 --- a/homeassistant/components/sonos/manifest.json +++ b/homeassistant/components/sonos/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/sonos", "iot_class": "local_push", "loggers": ["soco"], - "requirements": ["soco==0.29.1", "sonos-websocket==0.1.0"], + "requirements": ["soco==0.29.1", "sonos-websocket==0.1.1"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:ZonePlayer:1" diff --git a/requirements_all.txt b/requirements_all.txt index beb6fb884a06..f26d0fb4c399 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2384,7 +2384,7 @@ solax==0.3.0 somfy-mylink-synergy==1.0.6 # homeassistant.components.sonos -sonos-websocket==0.1.0 +sonos-websocket==0.1.1 # homeassistant.components.marytts speak2mary==1.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d1fdd2e72e76..6fc2f7b8342d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1720,7 +1720,7 @@ solax==0.3.0 somfy-mylink-synergy==1.0.6 # homeassistant.components.sonos -sonos-websocket==0.1.0 +sonos-websocket==0.1.1 # homeassistant.components.marytts speak2mary==1.4.0 From cc9de2cc21649b01d4571ef90c60eea9d3cc2b11 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 9 May 2023 08:54:55 +0200 Subject: [PATCH 277/822] Drop unused argument in hassio (#92820) --- homeassistant/components/hassio/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 42a51c218b12..ada2308ebc41 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -590,7 +590,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: await async_setup_addon_panel(hass, hassio) # Setup hardware integration for the detected board type - async def _async_setup_hardware_integration(_: datetime) -> None: + async def _async_setup_hardware_integration(_: datetime | None = None) -> None: """Set up hardaware integration for the detected board type.""" if (os_info := get_os_info(hass)) is None: # os info not yet fetched from supervisor, retry later @@ -610,7 +610,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: ) ) - await _async_setup_hardware_integration(datetime.now()) + await _async_setup_hardware_integration() hass.async_create_task( hass.config_entries.flow.async_init(DOMAIN, context={"source": "system"}) From 0199c6f5b23dca90d0bac9047b628ca5400771dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Tue, 9 May 2023 09:21:47 +0200 Subject: [PATCH 278/822] Update aioairzone to v0.5.5 (#92812) --- 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 a99d0ffd793d..991584dd8f8a 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.5.3"] + "requirements": ["aioairzone==0.5.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index f26d0fb4c399..e7cc34cd7ad2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -116,7 +116,7 @@ aio_georss_gdacs==0.8 aioairq==0.2.4 # homeassistant.components.airzone -aioairzone==0.5.3 +aioairzone==0.5.5 # homeassistant.components.ambient_station aioambient==2023.04.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6fc2f7b8342d..a9c19012bc62 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -106,7 +106,7 @@ aio_georss_gdacs==0.8 aioairq==0.2.4 # homeassistant.components.airzone -aioairzone==0.5.3 +aioairzone==0.5.5 # homeassistant.components.ambient_station aioambient==2023.04.0 From 25549eed857f026adc10475ed590685eaa3266d1 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 9 May 2023 16:36:19 +0200 Subject: [PATCH 279/822] Debounce and group mqtt unsubscribes (#92201) * Debounce MQTT unsubscribes and merge to one call * Make _async_unsubscribe a callback * Make sure unsubscribes are processed * Move debug log out of lock * Reduce calls and raise outside lock * Cancel any unsubscribe when queing * Copy pending unsubscribes * Only convert topics to list once * No copy needed * Typo in comment --- homeassistant/components/mqtt/client.py | 53 +++++++++++++++++-------- tests/components/mqtt/test_discovery.py | 11 ++++- tests/components/mqtt/test_init.py | 39 ++++++++++++++++++ 3 files changed, 84 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index ce538a6af13f..3ae880d2b839 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -90,6 +90,7 @@ _LOGGER = logging.getLogger(__name__) DISCOVERY_COOLDOWN = 2 INITIAL_SUBSCRIBE_COOLDOWN = 1.0 SUBSCRIBE_COOLDOWN = 0.1 +UNSUBSCRIBE_COOLDOWN = 0.1 TIMEOUT_ACK = 10 SubscribePayloadType = str | bytes # Only bytes if encoding is None @@ -387,6 +388,10 @@ class MQTT: ) self._max_qos: dict[str, int] = {} # topic, max qos self._pending_subscriptions: dict[str, int] = {} # topic, qos + self._unsubscribe_debouncer = EnsureJobAfterCooldown( + UNSUBSCRIBE_COOLDOWN, self._async_perform_unsubscribes + ) + self._pending_unsubscribes: set[str] = set() # topic if self.hass.state == CoreState.running: self._ha_started.set() @@ -510,6 +515,10 @@ class MQTT: await self._subscribe_debouncer.async_cleanup() # reset timeout to initial subscribe cooldown self._subscribe_debouncer.set_timeout(INITIAL_SUBSCRIBE_COOLDOWN) + # stop the unsubscribe debouncer + await self._unsubscribe_debouncer.async_cleanup() + # make sure the unsubscribes are processed + await self._async_perform_unsubscribes() # wait for ACKs to be processed async with self._pending_operations_condition: @@ -573,6 +582,9 @@ class MQTT: max_qos = max(qos, self._max_qos.setdefault(topic, qos)) self._max_qos[topic] = max_qos self._pending_subscriptions[topic] = max_qos + # Cancel any pending unsubscribe since we are subscribing now + if topic in self._pending_unsubscribes: + self._pending_unsubscribes.remove(topic) if queue_only: return self._subscribe_debouncer.async_schedule() @@ -608,22 +620,13 @@ class MQTT: self._matching_subscriptions.cache_clear() # Only unsubscribe if currently connected if self.connected: - self.hass.async_create_task(self._async_unsubscribe(topic)) + self._async_unsubscribe(topic) return async_remove - async def _async_unsubscribe(self, topic: str) -> None: - """Unsubscribe from a topic. - - This method is a coroutine. - """ - - def _client_unsubscribe(topic: str) -> int: - result, mid = self._mqttc.unsubscribe(topic) - _LOGGER.debug("Unsubscribing from %s, mid: %s", topic, mid) - _raise_on_error(result) - return mid - + @callback + def _async_unsubscribe(self, topic: str) -> None: + """Unsubscribe from a topic.""" if self._is_active_subscription(topic): if self._max_qos[topic] == 0: return @@ -636,11 +639,9 @@ class MQTT: if topic in self._pending_subscriptions: # avoid any pending subscription to be executed del self._pending_subscriptions[topic] - async with self._paho_lock: - mid = await self.hass.async_add_executor_job(_client_unsubscribe, topic) - await self._register_mid(mid) - self.hass.async_create_task(self._wait_for_mid(mid)) + self._pending_unsubscribes.add(topic) + self._unsubscribe_debouncer.async_schedule() async def _async_perform_subscriptions(self) -> None: """Perform MQTT client subscriptions.""" @@ -677,6 +678,24 @@ class MQTT: else: _raise_on_error(result) + async def _async_perform_unsubscribes(self) -> None: + """Perform pending MQTT client unsubscribes.""" + if not self._pending_unsubscribes: + return + + topics = list(self._pending_unsubscribes) + self._pending_unsubscribes = set() + + async with self._paho_lock: + result, mid = await self.hass.async_add_executor_job( + self._mqttc.unsubscribe, topics + ) + _raise_on_error(result) + for topic in topics: + _LOGGER.debug("Unsubscribing from %s, mid: %s", topic, mid) + + await self._wait_for_mid(mid) + def _mqtt_on_connect( self, _mqttc: mqtt.Client, diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index 800809f15ad5..8d3c43744fcf 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -1,4 +1,5 @@ """The tests for the MQTT discovery.""" +import asyncio import copy import json from pathlib import Path @@ -1376,6 +1377,7 @@ async def test_complex_discovery_topic_prefix( @patch("homeassistant.components.mqtt.PLATFORMS", []) @patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 0.0) @patch("homeassistant.components.mqtt.client.SUBSCRIBE_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.UNSUBSCRIBE_COOLDOWN", 0.0) async def test_mqtt_integration_discovery_subscribe_unsubscribe( hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient, @@ -1407,15 +1409,18 @@ async def test_mqtt_integration_discovery_subscribe_unsubscribe( return self.async_abort(reason="already_configured") with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}): + await asyncio.sleep(0.1) assert ("comp/discovery/#", 0) in help_all_subscribe_calls(mqtt_client_mock) assert not mqtt_client_mock.unsubscribe.called async_fire_mqtt_message(hass, "comp/discovery/bla/config", "") + await asyncio.sleep(0.1) await hass.async_block_till_done() - mqtt_client_mock.unsubscribe.assert_called_once_with("comp/discovery/#") + mqtt_client_mock.unsubscribe.assert_called_once_with(["comp/discovery/#"]) mqtt_client_mock.unsubscribe.reset_mock() async_fire_mqtt_message(hass, "comp/discovery/bla/config", "") + await asyncio.sleep(0.1) await hass.async_block_till_done() assert not mqtt_client_mock.unsubscribe.called @@ -1423,6 +1428,7 @@ async def test_mqtt_integration_discovery_subscribe_unsubscribe( @patch("homeassistant.components.mqtt.PLATFORMS", []) @patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 0.0) @patch("homeassistant.components.mqtt.client.SUBSCRIBE_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.UNSUBSCRIBE_COOLDOWN", 0.0) async def test_mqtt_discovery_unsubscribe_once( hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient, @@ -1456,9 +1462,10 @@ async def test_mqtt_discovery_unsubscribe_once( with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}): async_fire_mqtt_message(hass, "comp/discovery/bla/config", "") async_fire_mqtt_message(hass, "comp/discovery/bla/config", "") + await asyncio.sleep(0.1) await hass.async_block_till_done() await hass.async_block_till_done() - mqtt_client_mock.unsubscribe.assert_called_once_with("comp/discovery/#") + mqtt_client_mock.unsubscribe.assert_called_once_with(["comp/discovery/#"]) @patch("homeassistant.components.mqtt.PLATFORMS", [Platform.SENSOR]) diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index dec24128cc6f..5494b24c3986 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -906,6 +906,45 @@ async def test_subscribe_topic( unsub() +@patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.UNSUBSCRIBE_COOLDOWN", 0.2) +async def test_subscribe_and_resubscribe( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + mqtt_client_mock: MqttMockPahoClient, + calls: list[ReceiveMessage], + record_calls: MessageCallbackType, +) -> None: + """Test resubscribing within the debounce time.""" + mqtt_mock = await mqtt_mock_entry() + # Fake that the client is connected + mqtt_mock().connected = True + + unsub = await mqtt.async_subscribe(hass, "test-topic", record_calls) + # This unsub will be un-done with the following subscribe + # unsubscribe should not be called at the broker + unsub() + await asyncio.sleep(0.1) + unsub = await mqtt.async_subscribe(hass, "test-topic", record_calls) + await asyncio.sleep(0.1) + await hass.async_block_till_done() + + async_fire_mqtt_message(hass, "test-topic", "test-payload") + await hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0].topic == "test-topic" + assert calls[0].payload == "test-payload" + # assert unsubscribe was not called + mqtt_client_mock.unsubscribe.assert_not_called() + + unsub() + + await asyncio.sleep(0.2) + await hass.async_block_till_done() + mqtt_client_mock.unsubscribe.assert_called_once_with(["test-topic"]) + + async def test_subscribe_topic_non_async( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, From 5f7496f97bbc976141e0e959f6debd5bbf3a5ed6 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 9 May 2023 17:07:23 +0200 Subject: [PATCH 280/822] Fix lingering timer in hue light tests (#92858) --- tests/components/hue/test_light_v1.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/components/hue/test_light_v1.py b/tests/components/hue/test_light_v1.py index 59e6ddf34885..abdbb8163643 100644 --- a/tests/components/hue/test_light_v1.py +++ b/tests/components/hue/test_light_v1.py @@ -8,6 +8,7 @@ from homeassistant.components import hue from homeassistant.components.hue.const import CONF_ALLOW_HUE_GROUPS from homeassistant.components.hue.v1 import light as hue_light from homeassistant.components.light import ColorMode +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.util import color @@ -178,6 +179,8 @@ async def setup_bridge(hass, mock_bridge_v1): """Load the Hue light platform with the provided bridge.""" hass.config.components.add(hue.DOMAIN) config_entry = create_config_entry() + config_entry.add_to_hass(hass) + config_entry.state = ConfigEntryState.LOADED config_entry.options = {CONF_ALLOW_HUE_GROUPS: True} mock_bridge_v1.config_entry = config_entry hass.data[hue.DOMAIN] = {config_entry.entry_id: mock_bridge_v1} From 923276a333cea25da5c5ae5e016c4c9fbfc18dc8 Mon Sep 17 00:00:00 2001 From: kernelpanic85 <80057019+kernelpanic85@users.noreply.github.com> Date: Tue, 9 May 2023 12:28:10 -0400 Subject: [PATCH 281/822] Bump panacotta to 0.2 (#92797) --- homeassistant/components/panasonic_bluray/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/panasonic_bluray/manifest.json b/homeassistant/components/panasonic_bluray/manifest.json index 78c132eafbcc..fa0202c08718 100644 --- a/homeassistant/components/panasonic_bluray/manifest.json +++ b/homeassistant/components/panasonic_bluray/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/panasonic_bluray", "iot_class": "local_polling", "loggers": ["panacotta"], - "requirements": ["panacotta==0.1"] + "requirements": ["panacotta==0.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index e7cc34cd7ad2..2361fe366007 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1313,7 +1313,7 @@ p1monitor==2.1.1 paho-mqtt==1.6.1 # homeassistant.components.panasonic_bluray -panacotta==0.1 +panacotta==0.2 # homeassistant.components.panasonic_viera panasonic_viera==0.3.6 From 337b59ba23a50b80666af211b04c60a5d693eaf6 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 9 May 2023 18:42:04 +0200 Subject: [PATCH 282/822] Fix lingering timer in hassio (#92854) --- homeassistant/components/hassio/__init__.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index ada2308ebc41..c8f4b69d429d 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -596,7 +596,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: # os info not yet fetched from supervisor, retry later async_track_point_in_utc_time( hass, - _async_setup_hardware_integration, + async_setup_hardware_integration_job, utcnow() + HASSIO_UPDATE_INTERVAL, ) return @@ -610,6 +610,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: ) ) + async_setup_hardware_integration_job = HassJob( + _async_setup_hardware_integration, cancel_on_shutdown=True + ) + await _async_setup_hardware_integration() hass.async_create_task( From 67c105130592846f61badceee9deffe0c72bb9bd Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 9 May 2023 18:55:55 +0200 Subject: [PATCH 283/822] Allow setting an entity's name by its device class (#90767) Co-authored-by: Franck Nijhof --- homeassistant/components/aranet/sensor.py | 14 +++++--- homeassistant/components/balboa/entity.py | 6 ++-- homeassistant/components/bond/button.py | 5 +++ homeassistant/components/brunt/cover.py | 2 +- homeassistant/components/freebox/home_base.py | 4 +-- homeassistant/components/incomfort/sensor.py | 4 +++ .../components/modbus/binary_sensor.py | 6 ++++ homeassistant/components/modbus/sensor.py | 6 ++++ .../components/point/alarm_control_panel.py | 2 +- homeassistant/components/shelly/utils.py | 13 +++++-- .../components/system_bridge/__init__.py | 8 +++-- homeassistant/components/tomorrowio/sensor.py | 5 +++ .../components/unifiprotect/entity.py | 11 +++++- .../components/vizio/media_player.py | 2 ++ homeassistant/components/wemo/sensor.py | 4 +++ homeassistant/components/zwave_js/entity.py | 6 +++- homeassistant/components/zwave_me/__init__.py | 10 ++---- homeassistant/helpers/entity.py | 34 ++++++++++++++++--- homeassistant/helpers/entity_platform.py | 13 +++++-- homeassistant/helpers/template_entity.py | 2 ++ tests/components/esphome/conftest.py | 19 ++++++++--- 21 files changed, 141 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/aranet/sensor.py b/homeassistant/components/aranet/sensor.py index 6ac27b1652b7..4596a7fd8af1 100644 --- a/homeassistant/components/aranet/sensor.py +++ b/homeassistant/components/aranet/sensor.py @@ -28,7 +28,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity import DEVICE_CLASS_NAME, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN @@ -105,6 +105,13 @@ def sensor_update_to_bluetooth_data_update( adv: Aranet4Advertisement, ) -> PassiveBluetoothDataUpdate: """Convert a sensor update to a Bluetooth data update.""" + entity_names: dict[PassiveBluetoothEntityKey, str | None] = {} + for key, desc in SENSOR_DESCRIPTIONS.items(): + # PassiveBluetoothDataUpdate does not support DEVICE_CLASS_NAME + # the assert satisfies the type checker and will catch attempts + # to use DEVICE_CLASS_NAME in the entity descriptions. + assert desc.name is not DEVICE_CLASS_NAME + entity_names[_device_key_to_bluetooth_entity_key(adv.device, key)] = desc.name return PassiveBluetoothDataUpdate( devices={adv.device.address: _sensor_device_info_to_hass(adv)}, entity_descriptions={ @@ -117,10 +124,7 @@ def sensor_update_to_bluetooth_data_update( ) for key in SENSOR_DESCRIPTIONS }, - entity_names={ - _device_key_to_bluetooth_entity_key(adv.device, key): desc.name - for key, desc in SENSOR_DESCRIPTIONS.items() - }, + entity_names=entity_names, ) diff --git a/homeassistant/components/balboa/entity.py b/homeassistant/components/balboa/entity.py index e50c35db4777..1b2c8d48f0bb 100644 --- a/homeassistant/components/balboa/entity.py +++ b/homeassistant/components/balboa/entity.py @@ -4,7 +4,7 @@ from __future__ import annotations from pybalboa import EVENT_UPDATE, SpaClient from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity import DeviceClassName, DeviceInfo, Entity from .const import DOMAIN @@ -12,7 +12,9 @@ from .const import DOMAIN class BalboaBaseEntity(Entity): """Balboa base entity.""" - def __init__(self, client: SpaClient, name: str | None = None) -> None: + def __init__( + self, client: SpaClient, name: str | DeviceClassName | None = None + ) -> None: """Initialize the control.""" mac = client.mac_address model = client.model diff --git a/homeassistant/components/bond/button.py b/homeassistant/components/bond/button.py index 32b76c6fcae0..d3fc58a35bea 100644 --- a/homeassistant/components/bond/button.py +++ b/homeassistant/components/bond/button.py @@ -35,6 +35,11 @@ class BondButtonEntityDescription( ): """Class to describe a Bond Button entity.""" + # BondEntity does not support DEVICE_CLASS_NAME + # Restrict the type to satisfy the type checker and catch attempts + # to use DEVICE_CLASS_NAME in the entity descriptions. + name: str | None = None + STOP_BUTTON = BondButtonEntityDescription( key=Action.STOP, diff --git a/homeassistant/components/brunt/cover.py b/homeassistant/components/brunt/cover.py index 3fb328ab7fbc..1df34929b1cb 100644 --- a/homeassistant/components/brunt/cover.py +++ b/homeassistant/components/brunt/cover.py @@ -88,7 +88,7 @@ class BruntDevice( self._attr_attribution = ATTRIBUTION self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._attr_unique_id)}, # type: ignore[arg-type] - name=self._attr_name, + name=self._thing.name, via_device=(DOMAIN, self._entry_id), manufacturer="Brunt", sw_version=self._thing.fw_version, diff --git a/homeassistant/components/freebox/home_base.py b/homeassistant/components/freebox/home_base.py index c74f072a5be4..f10a9e047f73 100644 --- a/homeassistant/components/freebox/home_base.py +++ b/homeassistant/components/freebox/home_base.py @@ -30,8 +30,8 @@ class FreeboxHomeEntity(Entity): self._node = node self._sub_node = sub_node self._id = node["id"] - self._attr_name = node["label"].strip() - self._device_name = self._attr_name + self._device_name = node["label"].strip() + self._attr_name = self._device_name self._attr_unique_id = f"{self._router.mac}-node_{self._id}" if sub_node is not None: diff --git a/homeassistant/components/incomfort/sensor.py b/homeassistant/components/incomfort/sensor.py index b1b391aaaab0..0205e690c429 100644 --- a/homeassistant/components/incomfort/sensor.py +++ b/homeassistant/components/incomfort/sensor.py @@ -28,6 +28,10 @@ class IncomfortSensorEntityDescription(SensorEntityDescription): """Describes Incomfort sensor entity.""" extra_key: str | None = None + # IncomfortSensor does not support DEVICE_CLASS_NAME + # Restrict the type to satisfy the type checker and catch attempts + # to use DEVICE_CLASS_NAME in the entity descriptions. + name: str | None = None SENSOR_TYPES: tuple[IncomfortSensorEntityDescription, ...] = ( diff --git a/homeassistant/components/modbus/binary_sensor.py b/homeassistant/components/modbus/binary_sensor.py index 4f416874f9d0..f78e3ef9d318 100644 --- a/homeassistant/components/modbus/binary_sensor.py +++ b/homeassistant/components/modbus/binary_sensor.py @@ -14,6 +14,7 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import DEVICE_CLASS_NAME from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -73,6 +74,11 @@ class ModbusBinarySensor(BasePlatform, RestoreEntity, BinarySensorEntity): # this ensures that idx = bit position of value in result # polling is done with the base class name = self._attr_name if self._attr_name else "modbus_sensor" + + # DataUpdateCoordinator does not support DEVICE_CLASS_NAME + # the assert satisfies the type checker and will catch attempts + # to use DEVICE_CLASS_NAME in _attr_name. + assert name is not DEVICE_CLASS_NAME self._coordinator = DataUpdateCoordinator( hass, _LOGGER, diff --git a/homeassistant/components/modbus/sensor.py b/homeassistant/components/modbus/sensor.py index ca8246577fd4..7c1c3b0a7913 100644 --- a/homeassistant/components/modbus/sensor.py +++ b/homeassistant/components/modbus/sensor.py @@ -17,6 +17,7 @@ from homeassistant.const import ( CONF_UNIT_OF_MEASUREMENT, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import DEVICE_CLASS_NAME from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import ( @@ -79,6 +80,11 @@ class ModbusRegisterSensor(BaseStructPlatform, RestoreSensor, SensorEntity): # this ensures that idx = bit position of value in result # polling is done with the base class name = self._attr_name if self._attr_name else "modbus_sensor" + + # DataUpdateCoordinator does not support DEVICE_CLASS_NAME + # the assert satisfies the type checker and will catch attempts + # to use DEVICE_CLASS_NAME in _attr_name. + assert name is not DEVICE_CLASS_NAME self._coordinator = DataUpdateCoordinator( hass, _LOGGER, diff --git a/homeassistant/components/point/alarm_control_panel.py b/homeassistant/components/point/alarm_control_panel.py index bfffb9344078..21469c2c5a5c 100644 --- a/homeassistant/components/point/alarm_control_panel.py +++ b/homeassistant/components/point/alarm_control_panel.py @@ -67,7 +67,7 @@ class MinutPointAlarmControl(AlarmControlPanelEntity): self._attr_device_info = DeviceInfo( identifiers={(POINT_DOMAIN, home_id)}, manufacturer="Minut", - name=self._attr_name, + name=self._home["name"], ) async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 55c08620e817..6b781918b4f6 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -19,6 +19,7 @@ from homeassistant.helpers.device_registry import ( async_get as dr_async_get, format_mac, ) +from homeassistant.helpers.entity import DEVICE_CLASS_NAME, DeviceClassName from homeassistant.helpers.entity_registry import async_get as er_async_get from homeassistant.helpers.typing import EventType from homeassistant.util.dt import utcnow @@ -72,12 +73,16 @@ def get_number_of_channels(device: BlockDevice, block: Block) -> int: def get_block_entity_name( device: BlockDevice, block: Block | None, - description: str | None = None, + description: str | DeviceClassName | None = None, ) -> str: """Naming for block based switch and sensors.""" channel_name = get_block_channel_name(device, block) if description: + # It's not possible to do string manipulations on DEVICE_CLASS_NAME + # the assert satisfies the type checker and will catch attempts + # to use DEVICE_CLASS_NAME as description. + assert description is not DEVICE_CLASS_NAME return f"{channel_name} {description.lower()}" return channel_name @@ -301,12 +306,16 @@ def get_rpc_channel_name(device: RpcDevice, key: str) -> str: def get_rpc_entity_name( - device: RpcDevice, key: str, description: str | None = None + device: RpcDevice, key: str, description: str | DeviceClassName | None = None ) -> str: """Naming for RPC based switch and sensors.""" channel_name = get_rpc_channel_name(device, key) if description: + # It's not possible to do string manipulations on DEVICE_CLASS_NAME + # the assert satisfies the type checker and will catch attempts + # to use DEVICE_CLASS_NAME as description. + assert description is not DEVICE_CLASS_NAME return f"{channel_name} {description.lower()}" return channel_name diff --git a/homeassistant/components/system_bridge/__init__.py b/homeassistant/components/system_bridge/__init__.py index 05e607d56edb..1c5d4a4204e1 100644 --- a/homeassistant/components/system_bridge/__init__.py +++ b/homeassistant/components/system_bridge/__init__.py @@ -30,7 +30,7 @@ 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.aiohttp_client import async_get_clientsession -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity import DEVICE_CLASS_NAME, DeviceClassName, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, MODULES @@ -279,13 +279,17 @@ class SystemBridgeEntity(CoordinatorEntity[SystemBridgeDataUpdateCoordinator]): coordinator: SystemBridgeDataUpdateCoordinator, api_port: int, key: str, - name: str | None, + name: str | DeviceClassName | None, ) -> None: """Initialize the System Bridge entity.""" super().__init__(coordinator) self._hostname = coordinator.data.system.hostname self._key = f"{self._hostname}_{key}" + # It's not possible to do string manipulations on DEVICE_CLASS_NAME + # the assert satisfies the type checker and will catch attempts + # to use DEVICE_CLASS_NAME as name. + assert name is not DEVICE_CLASS_NAME self._name = f"{self._hostname} {name}" self._configuration_url = ( f"http://{self._hostname}:{api_port}/app/settings.html" diff --git a/homeassistant/components/tomorrowio/sensor.py b/homeassistant/components/tomorrowio/sensor.py index 2b7d466d2f06..23218543744d 100644 --- a/homeassistant/components/tomorrowio/sensor.py +++ b/homeassistant/components/tomorrowio/sensor.py @@ -32,6 +32,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DEVICE_CLASS_NAME from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import slugify from homeassistant.util.unit_conversion import DistanceConverter, SpeedConverter @@ -349,6 +350,10 @@ class BaseTomorrowioSensorEntity(TomorrowioEntity, SensorEntity): """Initialize Tomorrow.io Sensor Entity.""" super().__init__(config_entry, coordinator, api_version) self.entity_description = description + # It's not possible to do string manipulations on DEVICE_CLASS_NAME + # the assert satisfies the type checker and will catch attempts + # to use DEVICE_CLASS_NAME in the entity descriptions. + assert description.name is not DEVICE_CLASS_NAME 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)}" diff --git a/homeassistant/components/unifiprotect/entity.py b/homeassistant/components/unifiprotect/entity.py index 134b55c4b0dc..9ca27c7174e2 100644 --- a/homeassistant/components/unifiprotect/entity.py +++ b/homeassistant/components/unifiprotect/entity.py @@ -22,7 +22,12 @@ from pyunifiprotect.data import ( from homeassistant.core import callback import homeassistant.helpers.device_registry as dr -from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription +from homeassistant.helpers.entity import ( + DEVICE_CLASS_NAME, + DeviceInfo, + Entity, + EntityDescription, +) from .const import ( ATTR_EVENT_ID, @@ -199,6 +204,10 @@ class ProtectDeviceEntity(Entity): self.entity_description = description self._attr_unique_id = f"{self.device.mac}_{description.key}" name = description.name or "" + # It's not possible to do string manipulations on DEVICE_CLASS_NAME + # the assert satisfies the type checker and will catch attempts + # to use DEVICE_CLASS_NAME in the entity descriptions. + assert name is not DEVICE_CLASS_NAME self._attr_name = f"{self.device.display_name} {name.title()}" self._attr_attribution = DEFAULT_ATTRIBUTION diff --git a/homeassistant/components/vizio/media_player.py b/homeassistant/components/vizio/media_player.py index a989cea488f8..bf52f053b325 100644 --- a/homeassistant/components/vizio/media_player.py +++ b/homeassistant/components/vizio/media_player.py @@ -131,6 +131,8 @@ async def async_setup_entry( class VizioDevice(MediaPlayerEntity): """Media Player implementation which performs REST requests to device.""" + _attr_name: str + def __init__( self, config_entry: ConfigEntry, diff --git a/homeassistant/components/wemo/sensor.py b/homeassistant/components/wemo/sensor.py index 634d4a9e41d0..3cfc9a16f300 100644 --- a/homeassistant/components/wemo/sensor.py +++ b/homeassistant/components/wemo/sensor.py @@ -28,6 +28,10 @@ from .wemo_device import DeviceCoordinator class AttributeSensorDescription(SensorEntityDescription): """SensorEntityDescription for WeMo AttributeSensor entities.""" + # AttributeSensor does not support DEVICE_CLASS_NAME + # the assert satisfies the type checker and will catch attempts + # to use DEVICE_CLASS_NAME in the entity descriptions. + name: str | None = None state_conversion: Callable[[StateType], StateType] | None = None unique_id_suffix: str | None = None diff --git a/homeassistant/components/zwave_js/entity.py b/homeassistant/components/zwave_js/entity.py index 2a57257ffbfb..1f43f9f5bdb4 100644 --- a/homeassistant/components/zwave_js/entity.py +++ b/homeassistant/components/zwave_js/entity.py @@ -8,7 +8,7 @@ from zwave_js_server.model.value import Value as ZwaveValue, get_value_id_str from homeassistant.config_entries import ConfigEntry from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity import DEVICE_CLASS_NAME, DeviceInfo, Entity from .const import DOMAIN, LOGGER from .discovery import ZwaveDiscoveryInfo @@ -136,6 +136,10 @@ class ZWaveBaseEntity(Entity): and self.entity_description and self.entity_description.name ): + # It's not possible to do string manipulations on DEVICE_CLASS_NAME + # the assert satisfies the type checker and will catch attempts + # to use DEVICE_CLASS_NAME in the entity descriptions. + assert self.entity_description.name is not DEVICE_CLASS_NAME name = self.entity_description.name if name_prefix: diff --git a/homeassistant/components/zwave_me/__init__.py b/homeassistant/components/zwave_me/__init__.py index 1740820d0ba5..f49d203cb26a 100644 --- a/homeassistant/components/zwave_me/__init__.py +++ b/homeassistant/components/zwave_me/__init__.py @@ -115,7 +115,7 @@ async def async_setup_platforms( class ZWaveMeEntity(Entity): """Representation of a ZWaveMe device.""" - def __init__(self, controller, device): + def __init__(self, controller: ZWaveMeController, device: ZWaveMeData) -> None: """Initialize the device.""" self.controller = controller self.device = device @@ -124,13 +124,9 @@ class ZWaveMeEntity(Entity): f"{self.controller.config.unique_id}-{self.device.id}" ) self._attr_should_poll = False - - @property - def device_info(self) -> DeviceInfo: - """Return device specific attributes.""" - return DeviceInfo( + self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self.device.deviceIdentifier)}, - name=self._attr_name, + name=device.title, manufacturer=self.device.manufacturer, sw_version=self.device.firmware, suggested_area=self.device.locationName, diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 001713505941..3cc655a7fd12 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -52,6 +52,16 @@ DATA_ENTITY_SOURCE = "entity_info" SOURCE_CONFIG_ENTRY = "config_entry" SOURCE_PLATFORM_CONFIG = "platform_config" + +class DeviceClassName(Enum): + """Singleton to use device class name.""" + + _singleton = 0 + + +DEVICE_CLASS_NAME = DeviceClassName._singleton # pylint: disable=protected-access + + # Used when converting float states to string: limit precision according to machine # epsilon to make the string representation readable FLOAT_PRECISION = abs(int(math.floor(math.log10(abs(sys.float_info.epsilon))))) - 1 @@ -219,7 +229,7 @@ class EntityDescription: force_update: bool = False icon: str | None = None has_entity_name: bool = False - name: str | None = None + name: str | DeviceClassName | None = None translation_key: str | None = None unit_of_measurement: str | None = None @@ -288,7 +298,7 @@ class Entity(ABC): _attr_extra_state_attributes: MutableMapping[str, Any] _attr_force_update: bool _attr_icon: str | None - _attr_name: str | None + _attr_name: str | DeviceClassName | None _attr_should_poll: bool = True _attr_state: StateType = STATE_UNKNOWN _attr_supported_features: int | None = None @@ -318,10 +328,24 @@ class Entity(ABC): return self.entity_description.has_entity_name return False + def _device_class_name(self) -> str | None: + """Return a translated name of the entity based on its device class.""" + assert self.platform + if not self.has_entity_name: + return None + device_class_key = self.device_class or "_" + name_translation_key = ( + f"component.{self.platform.domain}.entity_component." + f"{device_class_key}.name" + ) + return self.platform.component_translations.get(name_translation_key) + @property def name(self) -> str | None: """Return the name of the entity.""" if hasattr(self, "_attr_name"): + if self._attr_name is DEVICE_CLASS_NAME: + return self._device_class_name() return self._attr_name if self.translation_key is not None and self.has_entity_name: assert self.platform @@ -329,10 +353,12 @@ class Entity(ABC): f"component.{self.platform.platform_name}.entity.{self.platform.domain}" f".{self.translation_key}.name" ) - if name_translation_key in self.platform.entity_translations: - name: str = self.platform.entity_translations[name_translation_key] + if name_translation_key in self.platform.platform_translations: + name: str = self.platform.platform_translations[name_translation_key] return name if hasattr(self, "entity_description"): + if self.entity_description.name is DEVICE_CLASS_NAME: + return self._device_class_name() return self.entity_description.name return None diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 0f93dca6939c..25140d0516f0 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -125,7 +125,8 @@ class EntityPlatform: self.entity_namespace = entity_namespace self.config_entry: config_entries.ConfigEntry | None = None self.entities: dict[str, Entity] = {} - self.entity_translations: dict[str, Any] = {} + self.component_translations: dict[str, Any] = {} + self.platform_translations: dict[str, Any] = {} self._tasks: list[asyncio.Task[None]] = [] # Stop tracking tasks after setup is completed self._setup_complete = False @@ -279,7 +280,15 @@ class EntityPlatform: full_name = f"{self.domain}.{self.platform_name}" try: - self.entity_translations = await translation.async_get_translations( + self.component_translations = await translation.async_get_translations( + hass, hass.config.language, "entity_component", {self.domain} + ) + except Exception as err: # pylint: disable=broad-exception-caught + _LOGGER.debug( + "Could not load translations for %s", self.domain, exc_info=err + ) + try: + self.platform_translations = await translation.async_get_translations( hass, hass.config.language, "entity", {self.platform_name} ) except Exception as err: # pylint: disable=broad-exception-caught diff --git a/homeassistant/helpers/template_entity.py b/homeassistant/helpers/template_entity.py index c4eb8a1343d3..2b2774eea66d 100644 --- a/homeassistant/helpers/template_entity.py +++ b/homeassistant/helpers/template_entity.py @@ -172,6 +172,8 @@ class _TemplateAttribute: class TemplateEntity(Entity): """Entity that uses templates to calculate attributes.""" + _attr_name: str | None + _attr_available = True _attr_entity_picture = None _attr_icon = None diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index 4c01c2f7c5a2..381b9e99f03b 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -1,9 +1,10 @@ """esphome session fixtures.""" from __future__ import annotations +from asyncio import Event from unittest.mock import AsyncMock, Mock, patch -from aioesphomeapi import APIClient, APIVersion, DeviceInfo +from aioesphomeapi import APIClient, APIVersion, DeviceInfo, ReconnectLogic import pytest from zeroconf import Zeroconf @@ -158,10 +159,18 @@ async def mock_voice_assistant_v1_entry( mock_client.device_info = AsyncMock(return_value=device_info) mock_client.subscribe_voice_assistant = AsyncMock(return_value=Mock()) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - await hass.async_block_till_done() - await hass.async_block_till_done() + try_connect_done = Event() + real_try_connect = ReconnectLogic._try_connect + + async def mock_try_connect(self): + """Set an event when ReconnectLogic._try_connect has been awaited.""" + result = await real_try_connect(self) + try_connect_done.set() + return result + + with patch.object(ReconnectLogic, "_try_connect", mock_try_connect): + await hass.config_entries.async_setup(entry.entry_id) + await try_connect_done.wait() return entry From 7d29d584fdfe982fd734b068bb4960362a9f9dba Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 9 May 2023 19:58:00 +0200 Subject: [PATCH 284/822] Fix race in Alexa async_enable_proactive_mode (#92785) --- homeassistant/components/alexa/config.py | 18 ++++++++---------- tests/components/alexa/test_config.py | 21 +++++++++++++++++++++ 2 files changed, 29 insertions(+), 10 deletions(-) create mode 100644 tests/components/alexa/test_config.py diff --git a/homeassistant/components/alexa/config.py b/homeassistant/components/alexa/config.py index 159bfebc6241..e086d525cf12 100644 --- a/homeassistant/components/alexa/config.py +++ b/homeassistant/components/alexa/config.py @@ -17,11 +17,12 @@ _LOGGER = logging.getLogger(__name__) class AbstractConfig(ABC): """Hold the configuration for Alexa.""" - _unsub_proactive_report: asyncio.Task[CALLBACK_TYPE] | None = None + _unsub_proactive_report: CALLBACK_TYPE | None = None def __init__(self, hass: HomeAssistant) -> None: """Initialize abstract config.""" self.hass = hass + self._enable_proactive_mode_lock = asyncio.Lock() self._store = None async def async_initialize(self): @@ -67,20 +68,17 @@ class AbstractConfig(ABC): async def async_enable_proactive_mode(self): """Enable proactive mode.""" _LOGGER.debug("Enable proactive mode") - if self._unsub_proactive_report is None: - self._unsub_proactive_report = self.hass.async_create_task( - async_enable_proactive_mode(self.hass, self) + async with self._enable_proactive_mode_lock: + if self._unsub_proactive_report is not None: + return + self._unsub_proactive_report = await async_enable_proactive_mode( + self.hass, self ) - try: - await self._unsub_proactive_report - except Exception: - self._unsub_proactive_report = None - raise async def async_disable_proactive_mode(self): """Disable proactive mode.""" _LOGGER.debug("Disable proactive mode") - if unsub_func := await self._unsub_proactive_report: + if unsub_func := self._unsub_proactive_report: unsub_func() self._unsub_proactive_report = None diff --git a/tests/components/alexa/test_config.py b/tests/components/alexa/test_config.py new file mode 100644 index 000000000000..9536e3d471bb --- /dev/null +++ b/tests/components/alexa/test_config.py @@ -0,0 +1,21 @@ +"""Test config.""" +import asyncio +from unittest.mock import patch + +from homeassistant.core import HomeAssistant + +from .test_common import get_default_config + + +async def test_enable_proactive_mode_in_parallel(hass: HomeAssistant) -> None: + """Test enabling proactive mode does not happen in parallel.""" + config = get_default_config(hass) + + with patch( + "homeassistant.components.alexa.config.async_enable_proactive_mode" + ) as mock_enable_proactive_mode: + await asyncio.gather( + config.async_enable_proactive_mode(), config.async_enable_proactive_mode() + ) + + mock_enable_proactive_mode.assert_awaited_once() From 5d6ccd6a32da39a75ba72c2cff15a3eefc7053e8 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 9 May 2023 13:46:57 -0500 Subject: [PATCH 285/822] Allow "no" to match "nb" in language util (#92862) * Allow "no" to match "nb" * Adjust comparison for speed --- homeassistant/util/language.py | 43 ++++++++++++++++++++++++++-------- tests/util/test_language.py | 36 ++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 10 deletions(-) diff --git a/homeassistant/util/language.py b/homeassistant/util/language.py index 8324293e1366..615024e059cc 100644 --- a/homeassistant/util/language.py +++ b/homeassistant/util/language.py @@ -54,6 +54,20 @@ def is_region(language: str, region: str | None) -> bool: return True +def is_language_match(lang_1: str, lang_2: str) -> bool: + """Return true if two languages are considered the same.""" + if lang_1 == lang_2: + # Exact match + return True + + if {lang_1, lang_2} == {"no", "nb"}: + # no = spoken Norwegian + # nb = written Norwegian (Bokmål) + return True + + return False + + @dataclass class Dialect: """Language with optional region and script/code.""" @@ -71,26 +85,35 @@ class Dialect: # Regions are upper-cased self.region = self.region.upper() - def score(self, dialect: Dialect, country: str | None = None) -> float: + def score( + self, dialect: Dialect, country: str | None = None + ) -> tuple[float, float]: """Return score for match with another dialect where higher is better. Score < 0 indicates a failure to match. """ - if self.language != dialect.language: + if not is_language_match(self.language, dialect.language): # Not a match - return -1 + return (-1, 0) + + is_exact_language = self.language == dialect.language if (self.region is None) and (dialect.region is None): # Weak match with no region constraint - return 1 + # Prefer exact language match + return (2 if is_exact_language else 1, 0) if (self.region is not None) and (dialect.region is not None): if self.region == dialect.region: - # Exact language + region match - return math.inf + # Same language + region match + # Prefer exact language match + return ( + math.inf, + 1 if is_exact_language else 0, + ) # Regions are both set, but don't match - return 0 + return (0, 0) # Generate ordered list of preferred regions pref_regions = list( @@ -113,13 +136,13 @@ class Dialect: # More preferred regions are at the front. # Add 1 to boost above a weak match where no regions are set. - return 1 + (len(pref_regions) - region_idx) + return (1 + (len(pref_regions) - region_idx), 0) except ValueError: # Region was not in preferred list pass # Not a preferred region - return 0 + return (0, 0) @staticmethod def parse(tag: str) -> Dialect: @@ -169,4 +192,4 @@ def matches( ) # Score < 0 is not a match - return [tag for _dialect, score, tag in scored if score >= 0] + return [tag for _dialect, score, tag in scored if score[0] >= 0] diff --git a/tests/util/test_language.py b/tests/util/test_language.py index 70c38a38f00f..41f3ef4b301b 100644 --- a/tests/util/test_language.py +++ b/tests/util/test_language.py @@ -190,3 +190,39 @@ def test_sr_latn() -> None: "sr-CS", "sr-RS", ] + + +def test_no_nb_same() -> None: + """Test that the no/nb are interchangeable.""" + assert language.matches( + "no", + ["en-US", "en-GB", "nb"], + ) == ["nb"] + assert language.matches( + "nb", + ["en-US", "en-GB", "no"], + ) == ["no"] + + +def test_no_nb_prefer_exact() -> None: + """Test that the exact language is preferred even if an interchangeable language is available.""" + assert language.matches( + "no", + ["en-US", "en-GB", "nb", "no"], + ) == ["no", "nb"] + assert language.matches( + "no", + ["en-US", "en-GB", "no", "nb"], + ) == ["no", "nb"] + + +def test_no_nb_prefer_exact_regions() -> None: + """Test that the exact language/region is preferred.""" + assert language.matches( + "no-AA", + ["en-US", "en-GB", "nb-AA", "no-AA"], + ) == ["no-AA", "nb-AA"] + assert language.matches( + "no-AA", + ["en-US", "en-GB", "no-AA", "nb-AA"], + ) == ["no-AA", "nb-AA"] From 9e9073d5da3c7fcd56c408300cd2e71daf9ecf3d Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 9 May 2023 20:55:29 +0200 Subject: [PATCH 286/822] Move raise on MQTT publish outside lock (#92860) * Move raise outside lock * Move log too --- homeassistant/components/mqtt/client.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index 3ae880d2b839..3bd08403b786 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -465,15 +465,15 @@ class MQTT: msg_info = await self.hass.async_add_executor_job( self._mqttc.publish, topic, payload, qos, retain ) - _LOGGER.debug( - "Transmitting%s message on %s: '%s', mid: %s, qos: %s", - " retained" if retain else "", - topic, - payload, - msg_info.mid, - qos, - ) - _raise_on_error(msg_info.rc) + _LOGGER.debug( + "Transmitting%s message on %s: '%s', mid: %s, qos: %s", + " retained" if retain else "", + topic, + payload, + msg_info.mid, + qos, + ) + _raise_on_error(msg_info.rc) await self._wait_for_mid(msg_info.mid) async def async_connect(self) -> None: From 23e24d75dcaae04c888df37144e9f405f10014fa Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Tue, 9 May 2023 21:22:06 +0100 Subject: [PATCH 287/822] Don't try to restore unavailable nor unknown states (#92825) --- .../components/integration/sensor.py | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index d199b8808d32..d55a11366467 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -174,23 +174,23 @@ class IntegrationSensor(RestoreEntity, SensorEntity): 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(): - try: - self._state = Decimal(state.state) - except (DecimalException, ValueError) as err: - _LOGGER.warning( - "%s could not restore last state %s: %s", - self.entity_id, - state.state, - err, - ) - else: - self._attr_device_class = state.attributes.get(ATTR_DEVICE_CLASS) - if self._unit_of_measurement is None: - self._unit_of_measurement = state.attributes.get( - ATTR_UNIT_OF_MEASUREMENT + if (state := await self.async_get_last_state()) is not None: + if state.state == STATE_UNAVAILABLE: + self._attr_available = False + elif state.state != STATE_UNKNOWN: + try: + self._state = Decimal(state.state) + except (DecimalException, ValueError) as err: + _LOGGER.warning( + "%s could not restore last state %s: %s", + self.entity_id, + state.state, + err, ) + self._attr_device_class = state.attributes.get(ATTR_DEVICE_CLASS) + self._unit_of_measurement = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + @callback def calc_integration(event: Event) -> None: """Handle the sensor state changes.""" From be040f8259a8d9ab7aec25248d579e080896ee59 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 10 May 2023 01:52:38 +0200 Subject: [PATCH 288/822] Bump samsungtvws to 2.6.0 (#92876) --- homeassistant/components/samsungtv/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index d3e49c3bd4ca..027badc189f6 100644 --- a/homeassistant/components/samsungtv/manifest.json +++ b/homeassistant/components/samsungtv/manifest.json @@ -37,7 +37,7 @@ "requirements": [ "getmac==0.8.2", "samsungctl[websocket]==0.7.1", - "samsungtvws[async,encrypted]==2.5.0", + "samsungtvws[async,encrypted]==2.6.0", "wakeonlan==2.1.0", "async-upnp-client==0.33.1" ], diff --git a/requirements_all.txt b/requirements_all.txt index 2361fe366007..04a494536abe 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2293,7 +2293,7 @@ rxv==0.7.0 samsungctl[websocket]==0.7.1 # homeassistant.components.samsungtv -samsungtvws[async,encrypted]==2.5.0 +samsungtvws[async,encrypted]==2.6.0 # homeassistant.components.satel_integra satel_integra==0.3.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a9c19012bc62..bef201448cad 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1653,7 +1653,7 @@ rxv==0.7.0 samsungctl[websocket]==0.7.1 # homeassistant.components.samsungtv -samsungtvws[async,encrypted]==2.5.0 +samsungtvws[async,encrypted]==2.6.0 # homeassistant.components.dhcp scapy==2.5.0 From 0c0d59d3e209c715950bbeb7c996ab9cc3f36521 Mon Sep 17 00:00:00 2001 From: Arkadii Yakovets Date: Wed, 10 May 2023 00:47:42 -0700 Subject: [PATCH 289/822] Correct workday strings typo (#92880) --- homeassistant/components/workday/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/workday/strings.json b/homeassistant/components/workday/strings.json index f34af9ff9135..12ab729ff1b0 100644 --- a/homeassistant/components/workday/strings.json +++ b/homeassistant/components/workday/strings.json @@ -25,7 +25,7 @@ "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", - "province": "State, Terroritory, Province, Region of Country" + "province": "State, Territory, Province, Region of Country" } } }, From 649557ed2e92ed3f0f8b67506d4df9b62cb33bce Mon Sep 17 00:00:00 2001 From: Malte Franken Date: Wed, 10 May 2023 18:28:45 +1000 Subject: [PATCH 290/822] Add config flow to geo_json_events (#87062) * migrated geo_json_events integration to config flow * improve test coverage * code reformatting * fix tests * fix entity manager * changes after review * improve test coverage and fixed form * remove unused code * remove commented out code * changes after review * make title prettier * fixed tests * simplified code * changes after review * fix test * push deprecation out * changes after review * changes after review * changes after review * changes after review * changes after review * removed scan interval from user flow and import flow --- .../components/geo_json_events/__init__.py | 58 +++- .../components/geo_json_events/config_flow.py | 105 ++++++ .../components/geo_json_events/const.py | 7 +- .../geo_json_events/geo_location.py | 76 +++-- .../components/geo_json_events/manager.py | 53 ++- .../components/geo_json_events/manifest.json | 1 + .../components/geo_json_events/strings.json | 22 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- tests/components/geo_json_events/__init__.py | 16 + tests/components/geo_json_events/conftest.py | 37 ++ .../geo_json_events/test_config_flow.py | 125 +++++++ .../geo_json_events/test_geo_location.py | 318 ++++++------------ tests/components/geo_json_events/test_init.py | 76 +++++ 14 files changed, 632 insertions(+), 265 deletions(-) create mode 100644 homeassistant/components/geo_json_events/config_flow.py create mode 100644 homeassistant/components/geo_json_events/strings.json create mode 100644 tests/components/geo_json_events/conftest.py create mode 100644 tests/components/geo_json_events/test_config_flow.py create mode 100644 tests/components/geo_json_events/test_init.py diff --git a/homeassistant/components/geo_json_events/__init__.py b/homeassistant/components/geo_json_events/__init__.py index 0bc612b6e8b8..64b589f4f907 100644 --- a/homeassistant/components/geo_json_events/__init__.py +++ b/homeassistant/components/geo_json_events/__init__.py @@ -1 +1,57 @@ -"""The geo_json_events component.""" +"""The GeoJSON events component.""" +from __future__ import annotations + +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_registry import ( + async_entries_for_config_entry, + async_get, +) + +from .const import DOMAIN, PLATFORMS +from .manager import GeoJsonFeedEntityManager + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Set up the GeoJSON events component as config entry.""" + feeds = hass.data.setdefault(DOMAIN, {}) + # Create feed entity manager for all platforms. + manager = GeoJsonFeedEntityManager(hass, config_entry) + feeds[config_entry.entry_id] = manager + _LOGGER.debug("Feed entity manager added for %s", config_entry.entry_id) + await remove_orphaned_entities(hass, config_entry.entry_id) + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + await manager.async_init() + return True + + +async def remove_orphaned_entities(hass: HomeAssistant, entry_id: str) -> None: + """Remove orphaned geo_location entities. + + This is needed because when fetching data from the external feed this integration is + determining which entities need to be added, updated or removed by comparing the + current with the previous data. After a restart of Home Assistant the integration + has no previous data to compare against, and thus all entities managed by this + integration are removed after startup. + """ + entity_registry = async_get(hass) + orphaned_entries = async_entries_for_config_entry(entity_registry, entry_id) + if orphaned_entries is not None: + for entry in orphaned_entries: + if entry.domain == Platform.GEO_LOCATION: + _LOGGER.debug("Removing orphaned entry %s", entry.entity_id) + entity_registry.async_remove(entry.entity_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload the GeoJSON events config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + manager: GeoJsonFeedEntityManager = hass.data[DOMAIN].pop(entry.entry_id) + await manager.async_stop() + return unload_ok diff --git a/homeassistant/components/geo_json_events/config_flow.py b/homeassistant/components/geo_json_events/config_flow.py new file mode 100644 index 000000000000..cf58e8b57ce0 --- /dev/null +++ b/homeassistant/components/geo_json_events/config_flow.py @@ -0,0 +1,105 @@ +"""Config flow to configure the GeoJSON events integration.""" +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import ( + CONF_LATITUDE, + CONF_LOCATION, + CONF_LONGITUDE, + CONF_RADIUS, + CONF_URL, + UnitOfLength, +) +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import config_validation as cv, selector +from homeassistant.util.unit_conversion import DistanceConverter + +from .const import DEFAULT_RADIUS_IN_KM, DEFAULT_RADIUS_IN_M, DOMAIN + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_URL): cv.string, + vol.Required(CONF_LOCATION): selector.LocationSelector( + selector.LocationSelectorConfig(radius=True, icon="") + ), + } +) + +_LOGGER = logging.getLogger(__name__) + + +class GeoJsonEventsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a GeoJSON events config flow.""" + + async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: + """Import a config entry from configuration.yaml.""" + url: str = import_config[CONF_URL] + latitude: float = import_config.get(CONF_LATITUDE, self.hass.config.latitude) + longitude: float = import_config.get(CONF_LONGITUDE, self.hass.config.longitude) + self._async_abort_entries_match( + { + CONF_URL: url, + CONF_LATITUDE: latitude, + CONF_LONGITUDE: longitude, + } + ) + return self.async_create_entry( + title=f"{url} ({latitude}, {longitude})", + data={ + CONF_URL: url, + CONF_LATITUDE: latitude, + CONF_LONGITUDE: longitude, + CONF_RADIUS: import_config.get(CONF_RADIUS, DEFAULT_RADIUS_IN_KM), + }, + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the start of the config flow.""" + if not user_input: + suggested_values: Mapping[str, Any] = { + CONF_LOCATION: { + CONF_LATITUDE: self.hass.config.latitude, + CONF_LONGITUDE: self.hass.config.longitude, + CONF_RADIUS: DEFAULT_RADIUS_IN_M, + } + } + data_schema = self.add_suggested_values_to_schema( + DATA_SCHEMA, suggested_values + ) + return self.async_show_form( + step_id="user", + data_schema=data_schema, + ) + + url: str = user_input[CONF_URL] + location: dict[str, Any] = user_input[CONF_LOCATION] + latitude: float = location[CONF_LATITUDE] + longitude: float = location[CONF_LONGITUDE] + self._async_abort_entries_match( + { + CONF_URL: url, + CONF_LATITUDE: latitude, + CONF_LONGITUDE: longitude, + } + ) + return self.async_create_entry( + title=f"{url} ({latitude}, {longitude})", + data={ + CONF_URL: url, + CONF_LATITUDE: latitude, + CONF_LONGITUDE: longitude, + CONF_RADIUS: DistanceConverter.convert( + location[CONF_RADIUS], + UnitOfLength.METERS, + UnitOfLength.KILOMETERS, + ), + }, + ) diff --git a/homeassistant/components/geo_json_events/const.py b/homeassistant/components/geo_json_events/const.py index 4c73be3995ec..15f8b0a5b84f 100644 --- a/homeassistant/components/geo_json_events/const.py +++ b/homeassistant/components/geo_json_events/const.py @@ -4,11 +4,16 @@ from __future__ import annotations from datetime import timedelta from typing import Final +from homeassistant.const import Platform + DOMAIN: Final = "geo_json_events" +PLATFORMS: Final = [Platform.GEO_LOCATION] + ATTR_EXTERNAL_ID: Final = "external_id" DEFAULT_RADIUS_IN_KM: Final = 20.0 -DEFAULT_SCAN_INTERVAL: Final = timedelta(minutes=5) +DEFAULT_RADIUS_IN_M: Final = 20000.0 +DEFAULT_UPDATE_INTERVAL: Final = timedelta(seconds=300) SOURCE: Final = "geo_json_events" SIGNAL_DELETE_ENTITY: Final = "geo_json_events_delete_{}" diff --git a/homeassistant/components/geo_json_events/geo_location.py b/homeassistant/components/geo_json_events/geo_location.py index df2978b654e4..def8f77994ef 100644 --- a/homeassistant/components/geo_json_events/geo_location.py +++ b/homeassistant/components/geo_json_events/geo_location.py @@ -2,42 +2,41 @@ from __future__ import annotations from collections.abc import Callable -from datetime import timedelta import logging from typing import Any -from aio_geojson_generic_client import GenericFeedManager from aio_geojson_generic_client.feed_entry import GenericFeedEntry import voluptuous as vol from homeassistant.components.geo_location import PLATFORM_SCHEMA, GeolocationEvent +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS, - CONF_SCAN_INTERVAL, CONF_URL, - EVENT_HOMEASSISTANT_START, UnitOfLength, ) -from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from . import GeoJsonFeedEntityManager from .const import ( ATTR_EXTERNAL_ID, DEFAULT_RADIUS_IN_KM, - DEFAULT_SCAN_INTERVAL, + DOMAIN, SIGNAL_DELETE_ENTITY, SIGNAL_UPDATE_ENTITY, SOURCE, ) -from .manager import GeoJsonFeedEntityManager _LOGGER = logging.getLogger(__name__) +# Deprecated. PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_URL): cv.string, @@ -48,28 +47,15 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the GeoJSON Events platform.""" - url: str = config[CONF_URL] - scan_interval: timedelta = config.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) - coordinates: tuple[float, float] = ( - config.get(CONF_LATITUDE, hass.config.latitude), - config.get(CONF_LONGITUDE, hass.config.longitude), - ) - radius_in_km: float = config[CONF_RADIUS] - # Initialize the entity manager. - manager = GeoJsonFeedEntityManager( - hass, scan_interval, coordinates, url, radius_in_km - ) + manager: GeoJsonFeedEntityManager = hass.data[DOMAIN][entry.entry_id] @callback def async_add_geolocation( - feed_manager: GenericFeedManager, + feed_manager: GeoJsonFeedEntityManager, external_id: str, ) -> None: """Add geolocation entity from feed.""" @@ -77,15 +63,36 @@ async def async_setup_platform( _LOGGER.debug("Adding geolocation %s", new_entity) async_add_entities([new_entity], True) - async_dispatcher_connect(hass, manager.signal_new_entity, async_add_geolocation) + manager.listeners.append( + async_dispatcher_connect(hass, manager.signal_new_entity, async_add_geolocation) + ) + # Do not wait for update here so that the setup can be completed and because an + # update will fetch data from the feed via HTTP and then process that data. + entry.async_create_task(hass, manager.async_update()) + _LOGGER.debug("Geolocation setup done") - await manager.async_init() - async def start_feed_manager(event: Event) -> None: - """Start feed manager.""" - await manager.async_update() - - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, start_feed_manager) +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up the GeoJSON Events platform.""" + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml", + breaks_in_ha_version="2023.8.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + ) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + ) class GeoJsonLocationEvent(GeolocationEvent): @@ -95,10 +102,15 @@ class GeoJsonLocationEvent(GeolocationEvent): _attr_source = SOURCE _attr_unit_of_measurement = UnitOfLength.KILOMETERS - def __init__(self, feed_manager: GenericFeedManager, external_id: str) -> None: + def __init__( + self, + feed_manager: GeoJsonFeedEntityManager, + external_id: str, + ) -> None: """Initialize entity with data from feed entry.""" self._feed_manager = feed_manager self._external_id = external_id + self._attr_unique_id = f"{feed_manager.entry_id}_{external_id}" self._remove_signal_delete: Callable[[], None] self._remove_signal_update: Callable[[], None] diff --git a/homeassistant/components/geo_json_events/manager.py b/homeassistant/components/geo_json_events/manager.py index a999d224ac78..93f74831ecbc 100644 --- a/homeassistant/components/geo_json_events/manager.py +++ b/homeassistant/components/geo_json_events/manager.py @@ -1,18 +1,26 @@ """Entity manager for generic GeoJSON events.""" from __future__ import annotations -from datetime import datetime, timedelta +from collections.abc import Callable +from datetime import datetime import logging from aio_geojson_generic_client import GenericFeedManager from aio_geojson_generic_client.feed_entry import GenericFeedEntry +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS, CONF_URL from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_time_interval -from .const import DOMAIN, SIGNAL_DELETE_ENTITY, SIGNAL_UPDATE_ENTITY +from .const import ( + DEFAULT_UPDATE_INTERVAL, + DOMAIN, + SIGNAL_DELETE_ENTITY, + SIGNAL_UPDATE_ENTITY, +) _LOGGER = logging.getLogger(__name__) @@ -23,27 +31,28 @@ class GeoJsonFeedEntityManager: def __init__( self, hass: HomeAssistant, - scan_interval: timedelta, - coordinates: tuple[float, float], - url: str, - radius_in_km: float, + config_entry: ConfigEntry, ) -> None: """Initialize the GeoJSON Feed Manager.""" - - self._hass = hass + self._hass: HomeAssistant = hass + self.entry_id: str = config_entry.entry_id websession = aiohttp_client.async_get_clientsession(hass) - self._feed_manager = GenericFeedManager( + self._feed_manager: GenericFeedManager = GenericFeedManager( websession, self._generate_entity, self._update_entity, self._remove_entity, - coordinates, - url, - filter_radius=radius_in_km, + ( + config_entry.data[CONF_LATITUDE], + config_entry.data[CONF_LONGITUDE], + ), + config_entry.data[CONF_URL], + filter_radius=config_entry.data[CONF_RADIUS], ) - self._scan_interval = scan_interval - self.signal_new_entity = ( - f"{DOMAIN}_new_geolocation_{coordinates}-{url}-{radius_in_km}" + self._track_time_remove_callback: Callable[[], None] | None = None + self.listeners: list[Callable[[], None]] = [] + self.signal_new_entity: str = ( + f"{DOMAIN}_new_geolocation_{config_entry.entry_id}" ) async def async_init(self) -> None: @@ -54,7 +63,10 @@ class GeoJsonFeedEntityManager: await self.async_update() # Trigger updates at regular intervals. - async_track_time_interval(self._hass, update, self._scan_interval) + self._track_time_remove_callback = async_track_time_interval( + self._hass, update, DEFAULT_UPDATE_INTERVAL + ) + _LOGGER.debug("Feed entity manager initialized") async def async_update(self) -> None: @@ -62,6 +74,15 @@ class GeoJsonFeedEntityManager: await self._feed_manager.update() _LOGGER.debug("Feed entity manager updated") + async def async_stop(self) -> None: + """Stop this feed entity manager from refreshing.""" + for unsub_dispatcher in self.listeners: + unsub_dispatcher() + self.listeners = [] + if self._track_time_remove_callback: + self._track_time_remove_callback() + _LOGGER.debug("Feed entity manager stopped") + def get_entry(self, external_id: str) -> GenericFeedEntry | None: """Get feed entry by external id.""" return self._feed_manager.feed_entries.get(external_id) diff --git a/homeassistant/components/geo_json_events/manifest.json b/homeassistant/components/geo_json_events/manifest.json index 8e381449411c..b02339eb20ac 100644 --- a/homeassistant/components/geo_json_events/manifest.json +++ b/homeassistant/components/geo_json_events/manifest.json @@ -2,6 +2,7 @@ "domain": "geo_json_events", "name": "GeoJSON", "codeowners": ["@exxamalte"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/geo_json_events", "integration_type": "service", "iot_class": "cloud_polling", diff --git a/homeassistant/components/geo_json_events/strings.json b/homeassistant/components/geo_json_events/strings.json new file mode 100644 index 000000000000..e50369d6e749 --- /dev/null +++ b/homeassistant/components/geo_json_events/strings.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "user": { + "title": "Fill in your GeoJSON feed details.", + "data": { + "url": "[%key:common::config_flow::data::url%]", + "location": "[%key:common::config_flow::data::location%]" + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + } + }, + "issues": { + "deprecated_yaml": { + "title": "The GeoJSON feed YAML configuration is being removed", + "description": "Configuring a GeoJSON feed using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the GeoJSON feed YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 1372e94d66df..82401badcd2f 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -153,6 +153,7 @@ FLOWS = { "garages_amsterdam", "gdacs", "generic", + "geo_json_events", "geocaching", "geofency", "geonetnz_quakes", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index e00a9e75ce95..490f3cf9a077 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1893,7 +1893,7 @@ "geo_json_events": { "name": "GeoJSON", "integration_type": "service", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "geo_rss_events": { diff --git a/tests/components/geo_json_events/__init__.py b/tests/components/geo_json_events/__init__.py index 09a767385302..f95ee747bf3f 100644 --- a/tests/components/geo_json_events/__init__.py +++ b/tests/components/geo_json_events/__init__.py @@ -1 +1,17 @@ """Tests for the geo_json_events component.""" +from unittest.mock import MagicMock + + +def _generate_mock_feed_entry( + external_id: str, + title: str, + distance_to_home: float, + coordinates: tuple[float, float], +) -> MagicMock: + """Construct a mock feed entry for testing purposes.""" + feed_entry = MagicMock() + feed_entry.external_id = external_id + feed_entry.title = title + feed_entry.distance_to_home = distance_to_home + feed_entry.coordinates = coordinates + return feed_entry diff --git a/tests/components/geo_json_events/conftest.py b/tests/components/geo_json_events/conftest.py new file mode 100644 index 000000000000..db0ac38fe470 --- /dev/null +++ b/tests/components/geo_json_events/conftest.py @@ -0,0 +1,37 @@ +"""Configuration for GeoJSON Events tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.geo_json_events import DOMAIN +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS, CONF_URL + +from tests.common import MockConfigEntry + +URL = "http://geo.json.local/geo_json_events.json" + + +@pytest.fixture +def config_entry() -> MockConfigEntry: + """Create a mock GeoJSON Events config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_URL: URL, + CONF_LATITUDE: -41.2, + CONF_LONGITUDE: 174.7, + CONF_RADIUS: 25.0, + }, + title=f"{URL}, -41.2, 174.7", + unique_id=f"{URL}, -41.2, 174.7", + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock geo_json_events entry setup.""" + with patch( + "homeassistant.components.geo_json_events.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/geo_json_events/test_config_flow.py b/tests/components/geo_json_events/test_config_flow.py new file mode 100644 index 000000000000..440e8c760861 --- /dev/null +++ b/tests/components/geo_json_events/test_config_flow.py @@ -0,0 +1,125 @@ +"""Define tests for the GeoJSON Events config flow.""" +from datetime import timedelta + +import pytest + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.geo_json_events import DOMAIN +from homeassistant.const import ( + CONF_LATITUDE, + CONF_LOCATION, + CONF_LONGITUDE, + CONF_RADIUS, + CONF_SCAN_INTERVAL, + CONF_URL, +) +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.geo_json_events.conftest import URL + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +async def test_duplicate_error_user( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test that errors are shown when duplicates are added.""" + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["step_id"] == "user" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_URL: URL, + CONF_LOCATION: { + CONF_LATITUDE: -41.2, + CONF_LONGITUDE: 174.7, + CONF_RADIUS: 25.0, + }, + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_duplicate_error_import( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test that errors are shown when duplicates are added.""" + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_URL: URL, + CONF_LATITUDE: -41.2, + CONF_LONGITUDE: 174.7, + CONF_RADIUS: 25, + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_step_import(hass: HomeAssistant) -> None: + """Test that the import step works.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_URL: URL, + CONF_LATITUDE: -41.2, + CONF_LONGITUDE: 174.7, + CONF_RADIUS: 25, + # This custom scan interval will not be carried over into the configuration. + CONF_SCAN_INTERVAL: timedelta(minutes=4), + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert ( + result["title"] == "http://geo.json.local/geo_json_events.json (-41.2, 174.7)" + ) + assert result["data"] == { + CONF_URL: URL, + CONF_LATITUDE: -41.2, + CONF_LONGITUDE: 174.7, + CONF_RADIUS: 25, + } + + +async def test_step_user(hass: HomeAssistant) -> None: + """Test that the user step works.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["step_id"] == "user" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_URL: URL, + CONF_LOCATION: { + CONF_LATITUDE: -41.2, + CONF_LONGITUDE: 174.7, + CONF_RADIUS: 25000.0, + }, + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert ( + result["title"] == "http://geo.json.local/geo_json_events.json (-41.2, 174.7)" + ) + assert result["data"] == { + CONF_URL: URL, + CONF_LATITUDE: -41.2, + CONF_LONGITUDE: 174.7, + CONF_RADIUS: 25.0, + } diff --git a/tests/components/geo_json_events/test_geo_location.py b/tests/components/geo_json_events/test_geo_location.py index 529d78fd83ca..ce650925200d 100644 --- a/tests/components/geo_json_events/test_geo_location.py +++ b/tests/components/geo_json_events/test_geo_location.py @@ -1,267 +1,157 @@ """The tests for the geojson platform.""" -from unittest.mock import ANY, MagicMock, call, patch +from datetime import timedelta +from unittest.mock import ANY, call, patch from aio_geojson_generic_client import GenericFeed from freezegun import freeze_time -from homeassistant.components import geo_location from homeassistant.components.geo_json_events.const import ( ATTR_EXTERNAL_ID, - DEFAULT_SCAN_INTERVAL, + DEFAULT_UPDATE_INTERVAL, +) +from homeassistant.components.geo_location import ( + ATTR_SOURCE, + DOMAIN as GEO_LOCATION_DOMAIN, ) -from homeassistant.components.geo_location import ATTR_SOURCE from homeassistant.const import ( ATTR_FRIENDLY_NAME, ATTR_LATITUDE, ATTR_LONGITUDE, ATTR_UNIT_OF_MEASUREMENT, - CONF_LATITUDE, - CONF_LONGITUDE, CONF_RADIUS, + CONF_SCAN_INTERVAL, CONF_URL, - EVENT_HOMEASSISTANT_START, - UnitOfLength, + LENGTH_KILOMETERS, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.dispatcher import DATA_DISPATCHER +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util -from tests.common import assert_setup_component, async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.components.geo_json_events import _generate_mock_feed_entry +from tests.components.geo_json_events.conftest import URL -URL = "http://geo.json.local/geo_json_events.json" -CONFIG = { - geo_location.DOMAIN: [ - {"platform": "geo_json_events", CONF_URL: URL, CONF_RADIUS: 200} - ] -} - -CONFIG_WITH_CUSTOM_LOCATION = { - geo_location.DOMAIN: [ +CONFIG_LEGACY = { + GEO_LOCATION_DOMAIN: [ { "platform": "geo_json_events", CONF_URL: URL, - CONF_RADIUS: 200, - CONF_LATITUDE: 15.1, - CONF_LONGITUDE: 25.2, + CONF_RADIUS: 190, + CONF_SCAN_INTERVAL: timedelta(minutes=2), } ] } -def _generate_mock_feed_entry(external_id, title, distance_to_home, coordinates): - """Construct a mock feed entry for testing purposes.""" - feed_entry = MagicMock() - feed_entry.external_id = external_id - feed_entry.title = title - feed_entry.distance_to_home = distance_to_home - feed_entry.coordinates = coordinates - return feed_entry - - -async def test_setup(hass: HomeAssistant) -> None: - """Test the general setup of the platform.""" +async def test_setup_as_legacy_platform(hass: HomeAssistant) -> None: + """Test the setup with YAML legacy configuration.""" # Set up some mock feed entries for this test. - mock_entry_1 = _generate_mock_feed_entry("1234", "Title 1", 15.5, (-31.0, 150.0)) - mock_entry_2 = _generate_mock_feed_entry("2345", "Title 2", 20.5, (-31.1, 150.1)) - mock_entry_3 = _generate_mock_feed_entry("3456", "Title 3", 25.5, (-31.2, 150.2)) - mock_entry_4 = _generate_mock_feed_entry("4567", "Title 4", 12.5, (-31.3, 150.3)) - - # Patching 'utcnow' to gain more control over the timed update. - utcnow = dt_util.utcnow() - with freeze_time(utcnow), patch( - "aio_geojson_client.feed.GeoJsonFeed.update" - ) as mock_feed_update: - mock_feed_update.return_value = ( - "OK", - [mock_entry_1, mock_entry_2, mock_entry_3], - ) - with assert_setup_component(1, geo_location.DOMAIN): - assert await async_setup_component(hass, geo_location.DOMAIN, CONFIG) - await hass.async_block_till_done() - # Artificially trigger update. - hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - # Collect events. - await hass.async_block_till_done() - - all_states = hass.states.async_all() - assert len(all_states) == 3 - - state = hass.states.get("geo_location.title_1") - assert state is not None - assert state.name == "Title 1" - assert state.attributes == { - ATTR_EXTERNAL_ID: "1234", - ATTR_LATITUDE: -31.0, - ATTR_LONGITUDE: 150.0, - ATTR_FRIENDLY_NAME: "Title 1", - ATTR_UNIT_OF_MEASUREMENT: UnitOfLength.KILOMETERS, - ATTR_SOURCE: "geo_json_events", - } - assert round(abs(float(state.state) - 15.5), 7) == 0 - - state = hass.states.get("geo_location.title_2") - assert state is not None - assert state.name == "Title 2" - assert state.attributes == { - ATTR_EXTERNAL_ID: "2345", - ATTR_LATITUDE: -31.1, - ATTR_LONGITUDE: 150.1, - ATTR_FRIENDLY_NAME: "Title 2", - ATTR_UNIT_OF_MEASUREMENT: UnitOfLength.KILOMETERS, - ATTR_SOURCE: "geo_json_events", - } - assert round(abs(float(state.state) - 20.5), 7) == 0 - - state = hass.states.get("geo_location.title_3") - assert state is not None - assert state.name == "Title 3" - assert state.attributes == { - ATTR_EXTERNAL_ID: "3456", - ATTR_LATITUDE: -31.2, - ATTR_LONGITUDE: 150.2, - ATTR_FRIENDLY_NAME: "Title 3", - ATTR_UNIT_OF_MEASUREMENT: UnitOfLength.KILOMETERS, - ATTR_SOURCE: "geo_json_events", - } - assert round(abs(float(state.state) - 25.5), 7) == 0 - - # Simulate an update - one existing, one new entry, - # one outdated entry - mock_feed_update.return_value = ( - "OK", - [mock_entry_1, mock_entry_4, mock_entry_3], - ) - async_fire_time_changed(hass, utcnow + DEFAULT_SCAN_INTERVAL) - await hass.async_block_till_done() - - all_states = hass.states.async_all() - assert len(all_states) == 3 - - # Simulate an update - empty data, but successful update, - # so no changes to entities. - mock_feed_update.return_value = "OK_NO_DATA", None - async_fire_time_changed(hass, utcnow + 2 * DEFAULT_SCAN_INTERVAL) - await hass.async_block_till_done() - - all_states = hass.states.async_all() - assert len(all_states) == 3 - - # Simulate an update - empty data, removes all entities - mock_feed_update.return_value = "ERROR", None - async_fire_time_changed(hass, utcnow + 3 * DEFAULT_SCAN_INTERVAL) - await hass.async_block_till_done() - - all_states = hass.states.async_all() - assert len(all_states) == 0 - - -async def test_setup_with_custom_location(hass: HomeAssistant) -> None: - """Test the setup with a custom location.""" - # Set up some mock feed entries for this test. - mock_entry_1 = _generate_mock_feed_entry("1234", "Title 1", 2000.5, (-31.1, 150.1)) + mock_entry_1 = _generate_mock_feed_entry("1234", "Title 1", 20.5, (-31.1, 150.1)) with patch( "aio_geojson_generic_client.feed_manager.GenericFeed", wraps=GenericFeed, ) as mock_feed, patch( - "aio_geojson_client.feed.GeoJsonFeed.update" - ) as mock_feed_update: - mock_feed_update.return_value = "OK", [mock_entry_1] + "aio_geojson_client.feed.GeoJsonFeed.update", + return_value=("OK", [mock_entry_1]), + ): + assert await async_setup_component(hass, GEO_LOCATION_DOMAIN, CONFIG_LEGACY) + await hass.async_block_till_done() - with assert_setup_component(1, geo_location.DOMAIN): - assert await async_setup_component( - hass, geo_location.DOMAIN, CONFIG_WITH_CUSTOM_LOCATION - ) - await hass.async_block_till_done() + assert len(hass.states.async_entity_ids(GEO_LOCATION_DOMAIN)) == 1 - # Artificially trigger update. - hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - # Collect events. - await hass.async_block_till_done() - - all_states = hass.states.async_all() - assert len(all_states) == 1 - - assert mock_feed.call_args == call( - ANY, (15.1, 25.2), URL, filter_radius=200.0 - ) + assert mock_feed.call_args == call(ANY, ANY, URL, filter_radius=190.0) -async def test_setup_race_condition(hass: HomeAssistant) -> None: - """Test a particular race condition experienced.""" - # 1. Feed returns 1 entry -> Feed manager creates 1 entity. - # 2. Feed returns error -> Feed manager removes 1 entity. - # However, this stayed on and kept listening for dispatcher signals. - # 3. Feed returns 1 entry -> Feed manager creates 1 entity. - # 4. Feed returns 1 entry -> Feed manager updates 1 entity. - # Internally, the previous entity is updating itself, too. - # 5. Feed returns error -> Feed manager removes 1 entity. - # There are now 2 entities trying to remove themselves from HA, but - # the second attempt fails of course. - - # Set up some mock feed entries for this test. +async def test_entity_lifecycle( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + config_entry: MockConfigEntry, +) -> None: + """Test entity lifecycle..""" + config_entry.add_to_hass(hass) + # Set up a mock feed entries for this test. mock_entry_1 = _generate_mock_feed_entry("1234", "Title 1", 15.5, (-31.0, 150.0)) - delete_signal = "geo_json_events_delete_1234" - update_signal = "geo_json_events_update_1234" + mock_entry_2 = _generate_mock_feed_entry("2345", "Title 2", 20.5, (-31.1, 150.1)) + mock_entry_3 = _generate_mock_feed_entry("3456", "Title 3", 25.5, (-31.2, 150.2)) + mock_entry_4 = _generate_mock_feed_entry("4567", "Title 4", 12.5, (-31.3, 150.3)) - # Patching 'utcnow' to gain more control over the timed update. utcnow = dt_util.utcnow() with freeze_time(utcnow), patch( "aio_geojson_client.feed.GeoJsonFeed.update" - ) as mock_feed_update, assert_setup_component(1, geo_location.DOMAIN): - assert await async_setup_component(hass, geo_location.DOMAIN, CONFIG) + ) as mock_feed_update: + mock_feed_update.return_value = "OK", [mock_entry_1, mock_entry_2, mock_entry_3] + + # Load config entry. + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - mock_feed_update.return_value = "OK", [mock_entry_1] + # 3 geolocation and 1 sensor entities + assert len(hass.states.async_entity_ids(GEO_LOCATION_DOMAIN)) == 3 + assert len(entity_registry.entities) == 3 - # Artificially trigger update. - hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - # Collect events. + state = hass.states.get(f"{GEO_LOCATION_DOMAIN}.title_1") + assert state is not None + assert state.name == "Title 1" + assert state.attributes == { + ATTR_EXTERNAL_ID: "1234", + ATTR_LATITUDE: -31.0, + ATTR_LONGITUDE: 150.0, + ATTR_FRIENDLY_NAME: "Title 1", + ATTR_UNIT_OF_MEASUREMENT: LENGTH_KILOMETERS, + ATTR_SOURCE: "geo_json_events", + } + assert round(abs(float(state.state) - 15.5), 7) == 0 + + state = hass.states.get(f"{GEO_LOCATION_DOMAIN}.title_2") + assert state is not None + assert state.name == "Title 2" + assert state.attributes == { + ATTR_EXTERNAL_ID: "2345", + ATTR_LATITUDE: -31.1, + ATTR_LONGITUDE: 150.1, + ATTR_FRIENDLY_NAME: "Title 2", + ATTR_UNIT_OF_MEASUREMENT: LENGTH_KILOMETERS, + ATTR_SOURCE: "geo_json_events", + } + assert round(abs(float(state.state) - 20.5), 7) == 0 + + state = hass.states.get(f"{GEO_LOCATION_DOMAIN}.title_3") + assert state is not None + assert state.name == "Title 3" + assert state.attributes == { + ATTR_EXTERNAL_ID: "3456", + ATTR_LATITUDE: -31.2, + ATTR_LONGITUDE: 150.2, + ATTR_FRIENDLY_NAME: "Title 3", + ATTR_UNIT_OF_MEASUREMENT: LENGTH_KILOMETERS, + ATTR_SOURCE: "geo_json_events", + } + assert round(abs(float(state.state) - 25.5), 7) == 0 + + # Simulate an update - two existing, one new entry, + # one outdated entry + mock_feed_update.return_value = ( + "OK", + [mock_entry_1, mock_entry_4, mock_entry_3], + ) + async_fire_time_changed(hass, utcnow + DEFAULT_UPDATE_INTERVAL) await hass.async_block_till_done() - all_states = hass.states.async_all() - assert len(all_states) == 1 - assert len(hass.data[DATA_DISPATCHER][delete_signal]) == 1 - assert len(hass.data[DATA_DISPATCHER][update_signal]) == 1 + assert len(hass.states.async_entity_ids(GEO_LOCATION_DOMAIN)) == 3 + + # Simulate an update - empty data, but successful update, + # so no changes to entities. + mock_feed_update.return_value = "OK_NO_DATA", None + async_fire_time_changed(hass, utcnow + 2 * DEFAULT_UPDATE_INTERVAL) + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids(GEO_LOCATION_DOMAIN)) == 3 # Simulate an update - empty data, removes all entities mock_feed_update.return_value = "ERROR", None - async_fire_time_changed(hass, utcnow + DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass, utcnow + 3 * DEFAULT_UPDATE_INTERVAL) await hass.async_block_till_done() - all_states = hass.states.async_all() - assert len(all_states) == 0 - assert len(hass.data[DATA_DISPATCHER][delete_signal]) == 0 - assert len(hass.data[DATA_DISPATCHER][update_signal]) == 0 - - # Simulate an update - 1 entry - mock_feed_update.return_value = "OK", [mock_entry_1] - async_fire_time_changed(hass, utcnow + 2 * DEFAULT_SCAN_INTERVAL) - await hass.async_block_till_done() - - all_states = hass.states.async_all() - assert len(all_states) == 1 - assert len(hass.data[DATA_DISPATCHER][delete_signal]) == 1 - assert len(hass.data[DATA_DISPATCHER][update_signal]) == 1 - - # Simulate an update - 1 entry - mock_feed_update.return_value = "OK", [mock_entry_1] - async_fire_time_changed(hass, utcnow + 3 * DEFAULT_SCAN_INTERVAL) - await hass.async_block_till_done() - - all_states = hass.states.async_all() - assert len(all_states) == 1 - assert len(hass.data[DATA_DISPATCHER][delete_signal]) == 1 - assert len(hass.data[DATA_DISPATCHER][update_signal]) == 1 - - # Simulate an update - empty data, removes all entities - mock_feed_update.return_value = "ERROR", None - async_fire_time_changed(hass, utcnow + 4 * DEFAULT_SCAN_INTERVAL) - await hass.async_block_till_done() - - all_states = hass.states.async_all() - assert len(all_states) == 0 - # Ensure that delete and update signal targets are now empty. - assert len(hass.data[DATA_DISPATCHER][delete_signal]) == 0 - assert len(hass.data[DATA_DISPATCHER][update_signal]) == 0 + assert len(hass.states.async_entity_ids(GEO_LOCATION_DOMAIN)) == 0 diff --git a/tests/components/geo_json_events/test_init.py b/tests/components/geo_json_events/test_init.py new file mode 100644 index 000000000000..bc803b3e8d87 --- /dev/null +++ b/tests/components/geo_json_events/test_init.py @@ -0,0 +1,76 @@ +"""Define tests for the GeoJSON Events general setup.""" +from unittest.mock import patch + +from homeassistant.components.geo_json_events.const import DOMAIN +from homeassistant.components.geo_location import DOMAIN as GEO_LOCATION_DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry +from tests.components.geo_json_events import _generate_mock_feed_entry + + +async def test_component_unload_config_entry( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test that loading and unloading of a config entry works.""" + config_entry.add_to_hass(hass) + with patch( + "aio_geojson_generic_client.GenericFeedManager.update" + ) as mock_feed_manager_update: + # Load config entry. + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert mock_feed_manager_update.call_count == 1 + assert hass.data[DOMAIN][config_entry.entry_id] is not None + # Unload config entry. + assert await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + assert hass.data[DOMAIN].get(config_entry.entry_id) is None + + +async def test_remove_orphaned_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + config_entry: MockConfigEntry, +) -> None: + """Test removing orphaned geolocation entities.""" + config_entry.add_to_hass(hass) + + entity_registry.async_get_or_create( + GEO_LOCATION_DOMAIN, "geo_json_events", "1", config_entry=config_entry + ) + entity_registry.async_get_or_create( + GEO_LOCATION_DOMAIN, "geo_json_events", "2", config_entry=config_entry + ) + entity_registry.async_get_or_create( + GEO_LOCATION_DOMAIN, "geo_json_events", "3", config_entry=config_entry + ) + + # There should now be 3 "orphaned" entries available which will be removed + # when the component is set up. + entries = er.async_entries_for_config_entry(entity_registry, config_entry.entry_id) + assert len(entries) == 3 + + # Set up a mock feed entry for this test. + mock_entry_1 = _generate_mock_feed_entry( + "1234", + "Title 1", + 15.5, + (38.0, -3.0), + ) + + with patch( + "aio_geojson_client.feed.GeoJsonFeed.update", + return_value=("OK", [mock_entry_1]), + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # 1 geolocation entity. + entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + assert len(entries) == 1 + + assert len(hass.states.async_entity_ids(GEO_LOCATION_DOMAIN)) == 1 From 7439ca5b1a1f0a2454caae717b1ae293219cb3c3 Mon Sep 17 00:00:00 2001 From: Kashif Khan <361477+kashifkhan@users.noreply.github.com> Date: Wed, 10 May 2023 05:36:44 -0500 Subject: [PATCH 291/822] Bump azure-servicebus to 7.10.0 (#92866) --- .github/workflows/wheels.yml | 7 ------- homeassistant/components/azure_event_hub/__init__.py | 1 - homeassistant/components/azure_service_bus/manifest.json | 2 +- homeassistant/package_constraints.txt | 4 ---- requirements_all.txt | 2 +- script/gen_requirements_all.py | 5 ----- 6 files changed, 2 insertions(+), 19 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 616e25d91624..c735a4469384 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -141,7 +141,6 @@ jobs: run: | requirement_files="requirements_all.txt requirements_diff.txt" for requirement_file in ${requirement_files}; do - sed -i "s|# azure-servicebus|azure-servicebus|g" ${requirement_file} sed -i "s|# pybluez|pybluez|g" ${requirement_file} sed -i "s|# beacontools|beacontools|g" ${requirement_file} sed -i "s|# fritzconnection|fritzconnection|g" ${requirement_file} @@ -264,12 +263,6 @@ jobs: # beacontools requires PyBluez. # sed -i "s|# beacontools|beacontools|g" ${requirement_file} - # azure-servicebus requires uamqp, which requires OpenSSL 1.1 to - # compile/build. This is not available on Alpine 3.17. The compat - # layer offered by Alpine conflicts, so we have no way to build - # this package. - # sed -i "s|# azure-servicebus|azure-servicebus|g" ${requirement_file} - # It doesn't build for some reason, so we skip it for now. # Bumping to the latest version (4.7.0.72) supporting Python 3.11 # doesn't help. Reverted bump in #91871. There are 8 registered diff --git a/homeassistant/components/azure_event_hub/__init__.py b/homeassistant/components/azure_event_hub/__init__.py index 30417a7f94a1..4e69bea1a38d 100644 --- a/homeassistant/components/azure_event_hub/__init__.py +++ b/homeassistant/components/azure_event_hub/__init__.py @@ -155,7 +155,6 @@ class AzureEventHub: Suppress the INFO and below logging on the underlying packages, they are very verbose, even at INFO. """ - logging.getLogger("uamqp").setLevel(logging.WARNING) logging.getLogger("azure.eventhub").setLevel(logging.WARNING) self._listener_remover = self.hass.bus.async_listen( MATCH_ALL, self.async_listen diff --git a/homeassistant/components/azure_service_bus/manifest.json b/homeassistant/components/azure_service_bus/manifest.json index 1ec3c690c4ed..059f6300aec1 100644 --- a/homeassistant/components/azure_service_bus/manifest.json +++ b/homeassistant/components/azure_service_bus/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/azure_service_bus", "iot_class": "cloud_push", "loggers": ["azure"], - "requirements": ["azure-servicebus==7.8.0"] + "requirements": ["azure-servicebus==7.10.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 122e82c4bf8b..3384bc4f6519 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -152,10 +152,6 @@ matplotlib==3.6.1 # cryptography 40.0.1 is installed with botocore pyOpenSSL>=23.1.0 -# uamqp newer versions we currently can't build for armv7/armhf -# Limit this to Python 3.10, to not block Python 3.11 dev for now -uamqp==1.6.0;python_version<'3.11' - # protobuf must be in package constraints for the wheel # builder to build binary wheels protobuf==4.22.3 diff --git a/requirements_all.txt b/requirements_all.txt index 04a494536abe..74134c0574cb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -407,7 +407,7 @@ axis==48 azure-eventhub==5.11.1 # homeassistant.components.azure_service_bus -# azure-servicebus==7.8.0 +azure-servicebus==7.10.0 # homeassistant.components.baidu baidu-aip==1.6.6 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index f3479d477891..594c78c81aa5 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -24,7 +24,6 @@ COMMENT_REQUIREMENTS = ( "atenpdu", # depends on pysnmp which is not maintained at this time "avea", # depends on bluepy "avion", - "azure-servicebus", # depends on uamqp, which requires OpenSSL 1.1 "beacontools", "beewi_smartclim", # depends on bluepy "bluepy", @@ -157,10 +156,6 @@ matplotlib==3.6.1 # cryptography 40.0.1 is installed with botocore pyOpenSSL>=23.1.0 -# uamqp newer versions we currently can't build for armv7/armhf -# Limit this to Python 3.10, to not block Python 3.11 dev for now -uamqp==1.6.0;python_version<'3.11' - # protobuf must be in package constraints for the wheel # builder to build binary wheels protobuf==4.22.3 From 4d26486c982e1ba6373f219fbecc3e40b451b81a Mon Sep 17 00:00:00 2001 From: Dave T <17680170+davet2001@users.noreply.github.com> Date: Wed, 10 May 2023 14:53:00 +0100 Subject: [PATCH 292/822] Add minor comment to assist development in aurora_abb_powerone (#90936) Add comment to assist development --- homeassistant/components/aurora_abb_powerone/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/aurora_abb_powerone/__init__.py b/homeassistant/components/aurora_abb_powerone/__init__.py index 305a42d4dccf..b5dc236dfa24 100644 --- a/homeassistant/components/aurora_abb_powerone/__init__.py +++ b/homeassistant/components/aurora_abb_powerone/__init__.py @@ -7,6 +7,8 @@ # Developer note: # vscode devcontainer: use the following to access USB device: # "runArgs": ["-e", "GIT_EDITOR=code --wait", "--device=/dev/ttyUSB0"], +# and add the following to the end of script/bootstrap: +# sudo chmod 777 /dev/ttyUSB0 import logging From 74c26fb5e032d73d90997faefacc1ef77fa6025f Mon Sep 17 00:00:00 2001 From: Glenn Waters Date: Wed, 10 May 2023 12:03:38 -0400 Subject: [PATCH 293/822] Bump UPB integration library to 0.5.4 (#92879) --- homeassistant/components/upb/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/upb/manifest.json b/homeassistant/components/upb/manifest.json index 3702751ef447..00cebe1e0d91 100644 --- a/homeassistant/components/upb/manifest.json +++ b/homeassistant/components/upb/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/upb", "iot_class": "local_push", "loggers": ["upb_lib"], - "requirements": ["upb_lib==0.5.3"] + "requirements": ["upb_lib==0.5.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 74134c0574cb..8d9940592696 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2559,7 +2559,7 @@ unifi-discovery==1.1.7 unifiled==0.11 # homeassistant.components.upb -upb_lib==0.5.3 +upb_lib==0.5.4 # homeassistant.components.upcloud upcloud-api==2.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bef201448cad..0b1ecda1800d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1847,7 +1847,7 @@ ultraheat-api==0.5.1 unifi-discovery==1.1.7 # homeassistant.components.upb -upb_lib==0.5.3 +upb_lib==0.5.4 # homeassistant.components.upcloud upcloud-api==2.0.0 From 98b91bcad9cf2e2d7b255b789c8430b00a3bf33e Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 10 May 2023 12:04:46 -0400 Subject: [PATCH 294/822] Bump ZHA dependencies (#92870) --- 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 9407dc841479..1055d846544e 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -20,7 +20,7 @@ "zigpy_znp" ], "requirements": [ - "bellows==0.35.2", + "bellows==0.35.3", "pyserial==3.5", "pyserial-asyncio==0.6", "zha-quirks==0.0.99", diff --git a/requirements_all.txt b/requirements_all.txt index 8d9940592696..5197485a19b4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -428,7 +428,7 @@ beautifulsoup4==4.11.1 # beewi_smartclim==0.0.10 # homeassistant.components.zha -bellows==0.35.2 +bellows==0.35.3 # homeassistant.components.bmw_connected_drive bimmer_connected==0.13.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0b1ecda1800d..36d8acd00b4f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -361,7 +361,7 @@ base36==0.1.1 beautifulsoup4==4.11.1 # homeassistant.components.zha -bellows==0.35.2 +bellows==0.35.3 # homeassistant.components.bmw_connected_drive bimmer_connected==0.13.3 From 97cac66195cdb49b61298b3bd0bf03acba955eae Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 10 May 2023 18:52:10 +0200 Subject: [PATCH 295/822] Add counter.set_value service (#92863) --- homeassistant/components/counter/__init__.py | 27 ++++++ .../components/counter/services.yaml | 17 ++++ tests/components/counter/test_init.py | 91 ++++++++++++++++++- 3 files changed, 132 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/counter/__init__.py b/homeassistant/components/counter/__init__.py index 768491f60852..a77e43408827 100644 --- a/homeassistant/components/counter/__init__.py +++ b/homeassistant/components/counter/__init__.py @@ -44,6 +44,7 @@ SERVICE_DECREMENT = "decrement" SERVICE_INCREMENT = "increment" SERVICE_RESET = "reset" SERVICE_CONFIGURE = "configure" +SERVICE_SET_VALUE = "set_value" STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 @@ -124,6 +125,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: component.async_register_entity_service(SERVICE_INCREMENT, {}, "async_increment") component.async_register_entity_service(SERVICE_DECREMENT, {}, "async_decrement") component.async_register_entity_service(SERVICE_RESET, {}, "async_reset") + component.async_register_entity_service( + SERVICE_SET_VALUE, + {vol.Required(VALUE): cv.positive_int}, + "async_set_value", + ) component.async_register_entity_service( SERVICE_CONFIGURE, { @@ -261,6 +267,27 @@ class Counter(collection.CollectionEntity, RestoreEntity): self._state = self.compute_next_state(self._config[CONF_INITIAL]) self.async_write_ha_state() + @callback + def async_set_value(self, value: int) -> None: + """Set counter to value.""" + if (maximum := self._config.get(CONF_MAXIMUM)) is not None and value > maximum: + raise ValueError( + f"Value {value} for {self.entity_id} exceeding the maximum value of {maximum}" + ) + + if (minimum := self._config.get(CONF_MINIMUM)) is not None and value < minimum: + raise ValueError( + f"Value {value} for {self.entity_id} exceeding the minimum value of {minimum}" + ) + + if (step := self._config.get(CONF_STEP)) is not None and value % step != 0: + raise ValueError( + f"Value {value} for {self.entity_id} is not a multiple of the step size {step}" + ) + + self._state = value + self.async_write_ha_state() + @callback def async_configure(self, **kwargs) -> None: """Change the counter's settings with a service.""" diff --git a/homeassistant/components/counter/services.yaml b/homeassistant/components/counter/services.yaml index 1930ba0d45bc..d94d05dd7bd2 100644 --- a/homeassistant/components/counter/services.yaml +++ b/homeassistant/components/counter/services.yaml @@ -21,6 +21,23 @@ reset: entity: domain: counter +set_value: + name: Set + description: Set the counter value + target: + entity: + domain: counter + fields: + value: + name: Value + required: true + description: The new counter value the entity should be set to. + selector: + number: + min: 0 + max: 9223372036854775807 + mode: box + configure: name: Configure description: Change counter parameters. diff --git a/tests/components/counter/test_init.py b/tests/components/counter/test_init.py index 349695874cf8..5c6dbba71ce8 100644 --- a/tests/components/counter/test_init.py +++ b/tests/components/counter/test_init.py @@ -11,14 +11,18 @@ from homeassistant.components.counter import ( ATTR_STEP, CONF_ICON, CONF_INITIAL, + CONF_MAXIMUM, + CONF_MINIMUM, CONF_NAME, CONF_RESTORE, CONF_STEP, DEFAULT_INITIAL, DEFAULT_STEP, DOMAIN, + SERVICE_SET_VALUE, + VALUE, ) -from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_ICON, ATTR_NAME +from homeassistant.const import ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, ATTR_ICON, ATTR_NAME from homeassistant.core import Context, CoreState, HomeAssistant, State from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component @@ -124,7 +128,7 @@ async def test_config_options(hass: HomeAssistant) -> None: async def test_methods(hass: HomeAssistant) -> None: - """Test increment, decrement, and reset methods.""" + """Test increment, decrement, set value, and reset methods.""" config = {DOMAIN: {"test_1": {}}} assert await async_setup_component(hass, "counter", config) @@ -158,11 +162,31 @@ async def test_methods(hass: HomeAssistant) -> None: state = hass.states.get(entity_id) assert int(state.state) == 0 + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: entity_id, + VALUE: 5, + }, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == "5" + async def test_methods_with_config(hass: HomeAssistant) -> None: """Test increment, decrement, and reset methods with configuration.""" config = { - DOMAIN: {"test": {CONF_NAME: "Hello World", CONF_INITIAL: 10, CONF_STEP: 5}} + DOMAIN: { + "test": { + CONF_NAME: "Hello World", + CONF_INITIAL: 10, + CONF_STEP: 5, + CONF_MINIMUM: 5, + CONF_MAXIMUM: 20, + } + } } assert await async_setup_component(hass, "counter", config) @@ -190,6 +214,67 @@ async def test_methods_with_config(hass: HomeAssistant) -> None: state = hass.states.get(entity_id) assert int(state.state) == 15 + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: entity_id, + VALUE: 5, + }, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == "5" + + with pytest.raises( + ValueError, match=r"Value 25 for counter.test exceeding the maximum value of 20" + ): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: entity_id, + VALUE: 25, + }, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state.state == "5" + + with pytest.raises( + ValueError, match=r"Value 0 for counter.test exceeding the minimum value of 5" + ): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: entity_id, + VALUE: 0, + }, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state.state == "5" + + with pytest.raises( + ValueError, + match=r"Value 6 for counter.test is not a multiple of the step size 5", + ): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: entity_id, + VALUE: 6, + }, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state.state == "5" + async def test_initial_state_overrules_restore_state(hass: HomeAssistant) -> None: """Ensure states are restored on startup.""" From 0f2caf864a784b7d7cbe52f7074202726e4e8107 Mon Sep 17 00:00:00 2001 From: Flo Date: Wed, 10 May 2023 21:25:08 +0200 Subject: [PATCH 296/822] Alexa Intent: Use the 'id' field and expose nearest resolutions as variables (#86709) * Use the 'id' field and nearest resolutions Expose nearest Resolution (ID and Value) as Variables * Add more specific type hints * Change type definition of request * Add deprecation warning and remove variables * Remove deprecation warning & update tests * Fix wrong value assignment * revert future changes --- homeassistant/components/alexa/intent.py | 30 ++++-- tests/components/alexa/test_intent.py | 113 +++++++++++++++++++++++ 2 files changed, 134 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/alexa/intent.py b/homeassistant/components/alexa/intent.py index 4c8722347464..06f76b8806e0 100644 --- a/homeassistant/components/alexa/intent.py +++ b/homeassistant/components/alexa/intent.py @@ -1,6 +1,7 @@ """Support for Alexa skill service end point.""" import enum import logging +from typing import Any from homeassistant.components import http from homeassistant.core import callback @@ -180,12 +181,15 @@ async def async_handle_intent(hass, message): return alexa_response.as_dict() -def resolve_slot_synonyms(key, request): +def resolve_slot_data(key: str, request: dict[str, Any]) -> dict[str, str]: """Check slot request for synonym resolutions.""" - # Default to the spoken slot value if more than one or none are found. For + # Default to the spoken slot value if more than one or none are found. Always + # passes the id and name of the nearest possible slot resolution. For # reference to the request object structure, see the Alexa docs: # https://tinyurl.com/ybvm7jhs - resolved_value = request["value"] + resolved_data = {} + resolved_data["value"] = request["value"] + resolved_data["id"] = "" if ( "resolutions" in request @@ -200,20 +204,26 @@ def resolve_slot_synonyms(key, request): if entry["status"]["code"] != SYN_RESOLUTION_MATCH: continue - possible_values.extend([item["value"]["name"] for item in entry["values"]]) + possible_values.extend([item["value"] for item in entry["values"]]) + + # Always set id if available, otherwise an empty string is used as id + if len(possible_values) >= 1: + # Set ID if available + if "id" in possible_values[0]: + resolved_data["id"] = possible_values[0]["id"] # If there is only one match use the resolved value, otherwise the - # resolution cannot be determined, so use the spoken slot value + # resolution cannot be determined, so use the spoken slot value and empty string as id if len(possible_values) == 1: - resolved_value = possible_values[0] + resolved_data["value"] = possible_values[0]["name"] else: _LOGGER.debug( "Found multiple synonym resolutions for slot value: {%s: %s}", key, - resolved_value, + resolved_data["value"], ) - return resolved_value + return resolved_data class AlexaResponse: @@ -237,8 +247,10 @@ class AlexaResponse: continue _key = key.replace(".", "_") + _slot_data = resolve_slot_data(key, value) - self.variables[_key] = resolve_slot_synonyms(key, value) + self.variables[_key] = _slot_data["value"] + self.variables[_key + "_Id"] = _slot_data["id"] def add_card(self, card_type, title, content): """Add a card to the response.""" diff --git a/tests/components/alexa/test_intent.py b/tests/components/alexa/test_intent.py index c3c0fdcbd416..03546c0ed226 100644 --- a/tests/components/alexa/test_intent.py +++ b/tests/components/alexa/test_intent.py @@ -77,6 +77,12 @@ def alexa_client(event_loop, hass, hass_client): "text": "You told us your sign is {{ ZodiacSign }}.", } }, + "GetZodiacHoroscopeIDIntent": { + "speech": { + "type": "plain", + "text": "You told us your sign is {{ ZodiacSign_Id }}.", + } + }, "AMAZON.PlaybackAction": { "speech": { "type": "plain", @@ -299,6 +305,113 @@ async def test_intent_request_with_slots_and_synonym_resolution(alexa_client) -> assert text == "You told us your sign is Virgo." +async def test_intent_request_with_slots_and_synonym_id_resolution( + alexa_client, +) -> None: + """Test a request with slots, id and a name synonym.""" + data = { + "version": "1.0", + "session": { + "new": False, + "sessionId": SESSION_ID, + "application": {"applicationId": APPLICATION_ID}, + "attributes": { + "supportedHoroscopePeriods": { + "daily": True, + "weekly": False, + "monthly": False, + } + }, + "user": {"userId": "amzn1.account.AM3B00000000000000000000000"}, + }, + "request": { + "type": "IntentRequest", + "requestId": REQUEST_ID, + "timestamp": "2015-05-13T12:34:56Z", + "intent": { + "name": "GetZodiacHoroscopeIDIntent", + "slots": { + "ZodiacSign": { + "name": "ZodiacSign", + "value": "V zodiac", + "resolutions": { + "resolutionsPerAuthority": [ + { + "authority": AUTHORITY_ID, + "status": {"code": "ER_SUCCESS_MATCH"}, + "values": [{"value": {"name": "Virgo", "id": "1"}}], + } + ] + }, + } + }, + }, + }, + } + req = await _intent_req(alexa_client, data) + assert req.status == HTTPStatus.OK + data = await req.json() + text = data.get("response", {}).get("outputSpeech", {}).get("text") + assert text == "You told us your sign is 1." + + +async def test_intent_request_with_slots_and_multi_synonym_id_resolution( + alexa_client, +) -> None: + """Test a request with slots and multiple name synonyms (id).""" + data = { + "version": "1.0", + "session": { + "new": False, + "sessionId": SESSION_ID, + "application": {"applicationId": APPLICATION_ID}, + "attributes": { + "supportedHoroscopePeriods": { + "daily": True, + "weekly": False, + "monthly": False, + } + }, + "user": {"userId": "amzn1.account.AM3B00000000000000000000000"}, + }, + "request": { + "type": "IntentRequest", + "requestId": REQUEST_ID, + "timestamp": "2015-05-13T12:34:56Z", + "intent": { + "name": "GetZodiacHoroscopeIDIntent", + "slots": { + "ZodiacSign": { + "name": "ZodiacSign", + "value": "Virgio Test", + "resolutions": { + "resolutionsPerAuthority": [ + { + "authority": AUTHORITY_ID, + "status": {"code": "ER_SUCCESS_MATCH"}, + "values": [ + {"value": {"name": "Virgio Test", "id": "2"}} + ], + }, + { + "authority": AUTHORITY_ID, + "status": {"code": "ER_SUCCESS_MATCH"}, + "values": [{"value": {"name": "Virgo", "id": "1"}}], + }, + ] + }, + } + }, + }, + }, + } + req = await _intent_req(alexa_client, data) + assert req.status == HTTPStatus.OK + data = await req.json() + text = data.get("response", {}).get("outputSpeech", {}).get("text") + assert text == "You told us your sign is 2." + + async def test_intent_request_with_slots_and_multi_synonym_resolution( alexa_client, ) -> None: From 6250b0a23006f1bab5fde2eeaa448eee313754d2 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Thu, 11 May 2023 00:13:22 +0200 Subject: [PATCH 297/822] Add a custom panel for KNX with a group monitor (#92355) * Add KNX panel * provide project data for the panel group monitor * upload and delete project from panel * test project store * more tests * finish tests * use integers for DPTBinary payload monitor display * add project to diagnostics * require new frontend version * update knx_frontend * review suggestions * update xknxproject to 3.1.0 --------- Co-authored-by: Marvin Wichmann --- homeassistant/components/knx/__init__.py | 8 + homeassistant/components/knx/const.py | 20 + homeassistant/components/knx/diagnostics.py | 5 + homeassistant/components/knx/manifest.json | 11 +- homeassistant/components/knx/project.py | 117 +++++ homeassistant/components/knx/websocket.py | 251 ++++++++++ requirements_all.txt | 6 + requirements_test_all.txt | 6 + tests/components/knx/conftest.py | 79 ++- tests/components/knx/fixtures/project.json | 502 ++++++++++++++++++++ tests/components/knx/test_diagnostic.py | 31 ++ tests/components/knx/test_websocket.py | 282 +++++++++++ 12 files changed, 1302 insertions(+), 16 deletions(-) create mode 100644 homeassistant/components/knx/project.py create mode 100644 homeassistant/components/knx/websocket.py create mode 100644 tests/components/knx/fixtures/project.json create mode 100644 tests/components/knx/test_websocket.py diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index 91d1d9fa1c59..8fab84c0aea6 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -71,6 +71,7 @@ from .const import ( ) from .device import KNXInterfaceDevice from .expose import KNXExposeSensor, KNXExposeTime, create_knx_exposure +from .project import KNXProject from .schema import ( BinarySensorSchema, ButtonSchema, @@ -91,6 +92,7 @@ from .schema import ( ga_validator, sensor_type_validator, ) +from .websocket import register_panel _LOGGER = logging.getLogger(__name__) @@ -222,6 +224,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: conf = dict(conf) hass.data[DATA_KNX_CONFIG] = conf + return True @@ -304,6 +307,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: schema=SERVICE_KNX_EXPOSURE_REGISTER_SCHEMA, ) + await register_panel(hass) + return True @@ -368,6 +373,8 @@ class KNXModule: self.service_exposures: dict[str, KNXExposeSensor | KNXExposeTime] = {} self.entry = entry + self.project = KNXProject(hass=hass, entry=entry) + self.xknx = XKNX( connection_config=self.connection_config(), rate_limit=self.entry.data[CONF_KNX_RATE_LIMIT], @@ -393,6 +400,7 @@ class KNXModule: async def start(self) -> None: """Start XKNX object. Connect to tunneling or Routing device.""" + await self.project.load_project() await self.xknx.start() async def stop(self, event: Event | None = None) -> None: diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py index d006637abd17..858f1cefea07 100644 --- a/homeassistant/components/knx/const.py +++ b/homeassistant/components/knx/const.py @@ -1,9 +1,12 @@ """Constants for the KNX integration.""" from __future__ import annotations +from collections.abc import Awaitable, Callable from enum import Enum from typing import Final, TypedDict +from xknx.telegram import Telegram + from homeassistant.components.climate import ( PRESET_AWAY, PRESET_COMFORT, @@ -76,6 +79,9 @@ DATA_HASS_CONFIG: Final = "knx_hass_config" ATTR_COUNTER: Final = "counter" ATTR_SOURCE: Final = "source" +AsyncMessageCallbackType = Callable[[Telegram], Awaitable[None]] +MessageCallbackType = Callable[[Telegram], None] + class KNXConfigEntryData(TypedDict, total=False): """Config entry for the KNX integration.""" @@ -101,6 +107,20 @@ class KNXConfigEntryData(TypedDict, total=False): sync_latency_tolerance: int | None +class KNXBusMonitorMessage(TypedDict): + """KNX bus monitor message.""" + + destination_address: str + destination_text: str | None + payload: str + type: str + value: str | None + source_address: str + source_text: str | None + direction: str + timestamp: str + + class ColorTempModes(Enum): """Color temperature modes for config validation.""" diff --git a/homeassistant/components/knx/diagnostics.py b/homeassistant/components/knx/diagnostics.py index 60a41c9a408e..2fada718d313 100644 --- a/homeassistant/components/knx/diagnostics.py +++ b/homeassistant/components/knx/diagnostics.py @@ -40,6 +40,11 @@ async def async_get_config_entry_diagnostics( diag["config_entry_data"] = async_redact_data(dict(config_entry.data), TO_REDACT) + if proj_info := knx_module.project.info: + diag["project_info"] = async_redact_data(proj_info, "name") + else: + diag["project_info"] = None + raw_config = await conf_util.async_hass_config_yaml(hass) diag["configuration_yaml"] = raw_config.get(DOMAIN) try: diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index d3aeced46c99..ad61b812386e 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -1,13 +1,18 @@ { "domain": "knx", "name": "KNX", + "after_dependencies": ["panel_custom"], "codeowners": ["@Julius2342", "@farmio", "@marvin-w"], "config_flow": true, - "dependencies": ["file_upload"], + "dependencies": ["file_upload", "websocket_api"], "documentation": "https://www.home-assistant.io/integrations/knx", "integration_type": "hub", "iot_class": "local_push", - "loggers": ["xknx"], + "loggers": ["xknx", "xknxproject"], "quality_scale": "platinum", - "requirements": ["xknx==2.9.0"] + "requirements": [ + "xknx==2.9.0", + "xknxproject==3.1.0", + "knx_frontend==2023.5.2.143855" + ] } diff --git a/homeassistant/components/knx/project.py b/homeassistant/components/knx/project.py new file mode 100644 index 000000000000..274ef5cb9a3b --- /dev/null +++ b/homeassistant/components/knx/project.py @@ -0,0 +1,117 @@ +"""Handle KNX project data.""" +from __future__ import annotations + +from dataclasses import dataclass +import logging +from typing import Final + +from xknx.dpt import DPTBase +from xknxproject import XKNXProj +from xknxproject.models import ( + Device, + GroupAddress as GroupAddressModel, + KNXProject as KNXProjectModel, + ProjectInfo, +) + +from homeassistant.components.file_upload import process_uploaded_file +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.storage import Store + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STORAGE_VERSION: Final = 1 +STORAGE_KEY: Final = f"{DOMAIN}/knx_project.json" + + +@dataclass +class GroupAddressInfo: + """Group address info for runtime usage.""" + + address: str + name: str + description: str + dpt_main: int | None + dpt_sub: int | None + transcoder: type[DPTBase] | None + + +def _create_group_address_info(ga_model: GroupAddressModel) -> GroupAddressInfo: + """Convert GroupAddress dict value into GroupAddressInfo instance.""" + dpt = ga_model["dpt"] + transcoder = DPTBase.transcoder_by_dpt(dpt["main"], dpt.get("sub")) if dpt else None + return GroupAddressInfo( + address=ga_model["address"], + name=ga_model["name"], + description=ga_model["description"], + transcoder=transcoder, + dpt_main=dpt["main"] if dpt else None, + dpt_sub=dpt["sub"] if dpt else None, + ) + + +class KNXProject: + """Manage KNX project data.""" + + loaded: bool + devices: dict[str, Device] + group_addresses: dict[str, GroupAddressInfo] + info: ProjectInfo | None + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + ) -> None: + """Initialize project data.""" + self.hass = hass + self._store = Store[KNXProjectModel](hass, STORAGE_VERSION, STORAGE_KEY) + + self.initial_state() + + def initial_state(self) -> None: + """Set initial state for project data.""" + self.loaded = False + self.devices = {} + self.group_addresses = {} + self.info = None + + async def load_project(self, data: KNXProjectModel | None = None) -> None: + """Load project data from storage.""" + if project := data or await self._store.async_load(): + self.devices = project["devices"] + self.info = project["info"] + + for ga_model in project["group_addresses"].values(): + ga_info = _create_group_address_info(ga_model) + self.group_addresses[ga_info.address] = ga_info + + _LOGGER.debug( + "Loaded KNX project data with %s group addresses from storage", + len(self.group_addresses), + ) + self.loaded = True + + async def process_project_file(self, file_id: str, password: str) -> None: + """Process an uploaded project file.""" + + def _parse_project() -> KNXProjectModel: + with process_uploaded_file(self.hass, file_id) as file_path: + xknxproj = XKNXProj( + file_path, + password=password, + language=self.hass.config.language, + ) + return xknxproj.parse() + + project = await self.hass.async_add_executor_job(_parse_project) + await self._store.async_save(project) + await self.load_project(data=project) + + async def remove_project_file(self) -> None: + """Remove project file from storage.""" + await self._store.async_remove() + self.initial_state() diff --git a/homeassistant/components/knx/websocket.py b/homeassistant/components/knx/websocket.py new file mode 100644 index 000000000000..d5a41dce1461 --- /dev/null +++ b/homeassistant/components/knx/websocket.py @@ -0,0 +1,251 @@ +"""KNX Websocket API.""" +from __future__ import annotations + +from collections.abc import Callable +from typing import Final + +from knx_frontend import get_build_id, locate_dir +import voluptuous as vol +from xknx.dpt import DPTArray +from xknx.exceptions import XKNXException +from xknx.telegram import Telegram, TelegramDirection +from xknx.telegram.apci import GroupValueRead, GroupValueResponse, GroupValueWrite +from xknxproject.exceptions import XknxProjectException + +from homeassistant.components import panel_custom, websocket_api +from homeassistant.core import HomeAssistant, callback +import homeassistant.util.dt as dt_util + +from .const import ( + DOMAIN, + AsyncMessageCallbackType, + KNXBusMonitorMessage, + MessageCallbackType, +) +from .project import KNXProject + +URL_BASE: Final = "/knx_static" + + +async def register_panel(hass: HomeAssistant) -> None: + """Register the KNX Panel and Websocket API.""" + websocket_api.async_register_command(hass, ws_info) + websocket_api.async_register_command(hass, ws_project_file_process) + 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) + + if DOMAIN not in hass.data.get("frontend_panels", {}): + path = locate_dir() + build_id = get_build_id() + hass.http.register_static_path( + URL_BASE, path, cache_headers=(build_id != "dev") + ) + await panel_custom.async_register_panel( + hass=hass, + frontend_url_path=DOMAIN, + webcomponent_name="knx-frontend", + sidebar_title=DOMAIN.upper(), + sidebar_icon="mdi:bus-electric", + module_url=f"{URL_BASE}/entrypoint-{build_id}.js", + embed_iframe=True, + require_admin=True, + ) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "knx/info", + } +) +@callback +def ws_info( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, +) -> None: + """Handle get info command.""" + xknx = hass.data[DOMAIN].xknx + + _project_info = None + if project_info := hass.data[DOMAIN].project.info: + _project_info = { + "name": project_info["name"], + "last_modified": project_info["last_modified"], + "tool_version": project_info["tool_version"], + } + + connection.send_result( + msg["id"], + { + "version": xknx.version, + "connected": xknx.connection_manager.connected.is_set(), + "current_address": str(xknx.current_address), + "project": _project_info, + }, + ) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "knx/project_file_process", + vol.Required("file_id"): str, + vol.Required("password"): str, + } +) +@websocket_api.async_response +async def ws_project_file_process( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, +) -> None: + """Handle get info command.""" + knx_project = hass.data[DOMAIN].project + try: + await knx_project.process_project_file( + file_id=msg["file_id"], + password=msg["password"], + ) + except (ValueError, XknxProjectException) as err: + # ValueError could raise from file_upload integration + connection.send_error( + msg["id"], websocket_api.const.ERR_HOME_ASSISTANT_ERROR, str(err) + ) + return + + connection.send_result(msg["id"]) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "knx/project_file_remove", + } +) +@websocket_api.async_response +async def ws_project_file_remove( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, +) -> None: + """Handle get info command.""" + knx_project = hass.data[DOMAIN].project + await knx_project.remove_project_file() + connection.send_result(msg["id"]) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "knx/group_monitor_info", + } +) +@callback +def ws_group_monitor_info( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, +) -> None: + """Handle get info command of group monitor.""" + project_loaded = hass.data[DOMAIN].project.loaded + connection.send_result( + msg["id"], + {"project_loaded": bool(project_loaded)}, + ) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "knx/subscribe_telegrams", + } +) +@callback +def ws_subscribe_telegram( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, +) -> None: + """Subscribe to incoming and outgoing KNX telegrams.""" + project: KNXProject = hass.data[DOMAIN].project + + async def forward_telegrams(telegram: Telegram) -> None: + """Forward events to websocket.""" + payload: str + dpt_payload = None + if isinstance(telegram.payload, (GroupValueWrite, GroupValueResponse)): + dpt_payload = telegram.payload.value + if isinstance(dpt_payload, DPTArray): + payload = f"0x{bytes(dpt_payload.value).hex()}" + else: + payload = f"{dpt_payload.value:d}" + elif isinstance(telegram.payload, GroupValueRead): + payload = "" + else: + return + + direction = ( + "group_monitor_incoming" + if telegram.direction is TelegramDirection.INCOMING + else "group_monitor_outgoing" + ) + dst = str(telegram.destination_address) + src = str(telegram.source_address) + bus_message: KNXBusMonitorMessage = KNXBusMonitorMessage( + destination_address=dst, + destination_text=None, + payload=payload, + type=str(telegram.payload.__class__.__name__), + value=None, + source_address=src, + source_text=None, + direction=direction, + timestamp=dt_util.as_local(dt_util.utcnow()).strftime("%H:%M:%S.%f")[:-3], + ) + if project.loaded: + if ga_infos := project.group_addresses.get(dst): + bus_message["destination_text"] = ga_infos.name + if dpt_payload is not None and ga_infos.transcoder is not None: + try: + value = ga_infos.transcoder.from_knx(dpt_payload) + except XKNXException: + bus_message["value"] = "Error decoding value" + else: + unit = ( + f" {ga_infos.transcoder.unit}" + if ga_infos.transcoder.unit is not None + else "" + ) + bus_message["value"] = f"{value}{unit}" + if ia_infos := project.devices.get(src): + bus_message[ + "source_text" + ] = f"{ia_infos['manufacturer_name']} {ia_infos['name']}" + + connection.send_event( + msg["id"], + bus_message, + ) + + connection.subscriptions[msg["id"]] = async_subscribe_telegrams( + hass, forward_telegrams + ) + + connection.send_result(msg["id"]) + + +def async_subscribe_telegrams( + hass: HomeAssistant, + telegram_callback: AsyncMessageCallbackType | MessageCallbackType, +) -> Callable[[], None]: + """Subscribe to telegram received callback.""" + xknx = hass.data[DOMAIN].xknx + + unregister = xknx.telegram_queue.register_telegram_received_cb( + telegram_callback, match_for_outgoing=True + ) + + def async_remove() -> None: + """Remove callback.""" + xknx.telegram_queue.unregister_telegram_received_cb(unregister) + + return async_remove diff --git a/requirements_all.txt b/requirements_all.txt index 5197485a19b4..badce2c5bd7b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1021,6 +1021,9 @@ kegtron-ble==0.4.0 # homeassistant.components.kiwi kiwiki-client==0.1.1 +# homeassistant.components.knx +knx_frontend==2023.5.2.143855 + # homeassistant.components.konnected konnected==1.2.0 @@ -2663,6 +2666,9 @@ xiaomi-ble==0.17.0 # homeassistant.components.knx xknx==2.9.0 +# homeassistant.components.knx +xknxproject==3.1.0 + # homeassistant.components.bluesound # homeassistant.components.fritz # homeassistant.components.rest diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 36d8acd00b4f..6ae9df6d7558 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -780,6 +780,9 @@ justnimbus==0.6.0 # homeassistant.components.kegtron kegtron-ble==0.4.0 +# homeassistant.components.knx +knx_frontend==2023.5.2.143855 + # homeassistant.components.konnected konnected==1.2.0 @@ -1933,6 +1936,9 @@ xiaomi-ble==0.17.0 # homeassistant.components.knx xknx==2.9.0 +# homeassistant.components.knx +xknxproject==3.1.0 + # homeassistant.components.bluesound # homeassistant.components.fritz # homeassistant.components.rest diff --git a/tests/components/knx/conftest.py b/tests/components/knx/conftest.py index 9cf325086a2f..fcad9a7be89b 100644 --- a/tests/components/knx/conftest.py +++ b/tests/components/knx/conftest.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +import json from unittest.mock import DEFAULT, AsyncMock, Mock, patch import pytest @@ -11,7 +12,13 @@ from xknx.dpt import DPTArray, DPTBinary from xknx.io import DEFAULT_MCAST_GRP, DEFAULT_MCAST_PORT from xknx.telegram import Telegram, TelegramDirection from xknx.telegram.address import GroupAddress, IndividualAddress -from xknx.telegram.apci import APCI, GroupValueRead, GroupValueResponse, GroupValueWrite +from xknx.telegram.apci import ( + APCI, + GroupValueRead, + GroupValueResponse, + GroupValueWrite, + IndividualAddressRead, +) from homeassistant.components.knx.const import ( CONF_KNX_AUTOMATIC, @@ -26,10 +33,13 @@ from homeassistant.components.knx.const import ( DEFAULT_ROUTING_IA, DOMAIN as KNX_DOMAIN, ) +from homeassistant.components.knx.project import STORAGE_KEY as KNX_PROJECT_STORAGE_KEY from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_fixture + +FIXTURE_PROJECT_DATA = json.loads(load_fixture("project.json", KNX_DOMAIN)) class KNXTestKit: @@ -181,39 +191,72 @@ class KNXTestKit: return DPTBinary(payload) return DPTArray(payload) - async def _receive_telegram(self, group_address: str, payload: APCI) -> None: + async def _receive_telegram( + self, + group_address: str, + payload: APCI, + source: str | None = None, + ) -> None: """Inject incoming KNX telegram.""" self.xknx.telegrams.put_nowait( Telegram( destination_address=GroupAddress(group_address), direction=TelegramDirection.INCOMING, payload=payload, - source_address=IndividualAddress(self.INDIVIDUAL_ADDRESS), + source_address=IndividualAddress(source or self.INDIVIDUAL_ADDRESS), ) ) await self.xknx.telegrams.join() await self.hass.async_block_till_done() - async def receive_read( - self, - group_address: str, - ) -> None: + async def receive_individual_address_read(self, source: str | None = None): + """Inject incoming IndividualAddressRead telegram.""" + self.xknx.telegrams.put_nowait( + Telegram( + destination_address=IndividualAddress(self.INDIVIDUAL_ADDRESS), + direction=TelegramDirection.INCOMING, + payload=IndividualAddressRead(), + source_address=IndividualAddress(source or "1.3.5"), + ) + ) + await self.xknx.telegrams.join() + await self.hass.async_block_till_done() + + async def receive_read(self, group_address: str, source: str | None = None) -> None: """Inject incoming GroupValueRead telegram.""" - await self._receive_telegram(group_address, GroupValueRead()) + await self._receive_telegram( + group_address, + GroupValueRead(), + source=source, + ) async def receive_response( - self, group_address: str, payload: int | tuple[int, ...] + self, + group_address: str, + payload: int | tuple[int, ...], + source: str | None = None, ) -> None: """Inject incoming GroupValueResponse telegram.""" payload_value = self._payload_value(payload) - await self._receive_telegram(group_address, GroupValueResponse(payload_value)) + await self._receive_telegram( + group_address, + GroupValueResponse(payload_value), + source=source, + ) async def receive_write( - self, group_address: str, payload: int | tuple[int, ...] + self, + group_address: str, + payload: int | tuple[int, ...], + source: str | None = None, ) -> None: """Inject incoming GroupValueWrite telegram.""" payload_value = self._payload_value(payload) - await self._receive_telegram(group_address, GroupValueWrite(payload_value)) + await self._receive_telegram( + group_address, + GroupValueWrite(payload_value), + source=source, + ) @pytest.fixture @@ -239,3 +282,13 @@ async def knx(request, hass, mock_config_entry: MockConfigEntry): knx_test_kit = KNXTestKit(hass, mock_config_entry) yield knx_test_kit await knx_test_kit.assert_no_telegram() + + +@pytest.fixture +def load_knxproj(hass_storage): + """Mock KNX project data.""" + hass_storage[KNX_PROJECT_STORAGE_KEY] = { + "version": 1, + "data": FIXTURE_PROJECT_DATA, + } + return diff --git a/tests/components/knx/fixtures/project.json b/tests/components/knx/fixtures/project.json new file mode 100644 index 000000000000..60798d1b245f --- /dev/null +++ b/tests/components/knx/fixtures/project.json @@ -0,0 +1,502 @@ +{ + "info": { + "project_id": "P-04FF", + "name": "Fixture", + "last_modified": "2023-04-30T09:04:04.4043671Z", + "group_address_style": "ThreeLevel", + "guid": "6a019e80-5945-489e-95a3-378735c642d1", + "created_by": "ETS5", + "schema_version": "20", + "tool_version": "5.7.1428.39779", + "xknxproject_version": "3.1.0", + "language_code": "de-DE" + }, + "communication_objects": { + "1.0.9/O-57_R-21": { + "name": "Ch A Current Setpoint", + "number": 57, + "text": "Kanal A - Regler", + "function_text": "aktueller Sollwert", + "description": "", + "device_address": "1.0.9", + "dpts": [ + { + "main": 9, + "sub": 1 + } + ], + "object_size": "2 Bytes", + "flags": { + "read": true, + "write": false, + "communication": true, + "update": false, + "read_on_init": false, + "transmit": true + }, + "group_address_links": ["0/0/2"] + }, + "1.0.9/O-73_R-29": { + "name": "Ch A On/Off Request Master", + "number": 73, + "text": "Kanal A - Regler", + "function_text": "Regelung aktivieren/deaktivieren", + "description": "", + "device_address": "1.0.9", + "dpts": [ + { + "main": 1, + "sub": 1 + } + ], + "object_size": "1 Bit", + "flags": { + "read": false, + "write": true, + "communication": true, + "update": false, + "read_on_init": false, + "transmit": false + }, + "group_address_links": ["0/0/1"] + }, + "1.1.6/O-4_R-4": { + "name": "DayNight_General_1_GO", + "number": 4, + "text": "Zeit", + "function_text": "Tag (0) / Nacht (1)", + "description": "", + "device_address": "1.1.6", + "dpts": [ + { + "main": 1, + "sub": 24 + } + ], + "object_size": "1 Bit", + "flags": { + "read": false, + "write": true, + "communication": true, + "update": true, + "read_on_init": false, + "transmit": true + }, + "group_address_links": ["0/0/1"] + }, + "1.1.6/O-1_R-1": { + "name": "Time_General_1_GO", + "number": 1, + "text": "Zeit", + "function_text": "Uhrzeit", + "description": "", + "device_address": "1.1.6", + "dpts": [ + { + "main": 10, + "sub": 1 + } + ], + "object_size": "3 Bytes", + "flags": { + "read": false, + "write": true, + "communication": true, + "update": true, + "read_on_init": false, + "transmit": true + }, + "group_address_links": ["0/1/2"] + }, + "1.1.6/O-241_R-124": { + "name": "StatusOnOff_RGB_1_GO", + "number": 241, + "text": "RGB:", + "function_text": "Status An/Aus", + "description": "", + "device_address": "1.1.6", + "dpts": [ + { + "main": 1, + "sub": 1 + } + ], + "object_size": "1 Bit", + "flags": { + "read": true, + "write": false, + "communication": true, + "update": false, + "read_on_init": false, + "transmit": true + }, + "group_address_links": ["0/1/0"] + }, + "2.0.5/O-107_R-61": { + "name": "UHRZEIT", + "number": 107, + "text": "Uhrzeit", + "function_text": "Eingang / Ausgang", + "description": "", + "device_address": "2.0.5", + "dpts": [ + { + "main": 10, + "sub": 1 + } + ], + "object_size": "3 Bytes", + "flags": { + "read": true, + "write": true, + "communication": true, + "update": false, + "read_on_init": false, + "transmit": true + }, + "group_address_links": ["0/0/3"] + }, + "2.0.5/O-123_R-3923": { + "name": "T_MW_INTERN", + "number": 123, + "text": "Temp.Sensor: Messwert", + "function_text": "Ausgang", + "description": "", + "device_address": "2.0.5", + "dpts": [ + { + "main": 9, + "sub": 1 + } + ], + "object_size": "2 Bytes", + "flags": { + "read": true, + "write": false, + "communication": true, + "update": false, + "read_on_init": false, + "transmit": true + }, + "group_address_links": ["0/0/2"] + }, + "2.0.5/O-331_R-254": { + "name": "NACHT_SA", + "number": 331, + "text": "Nacht: Schaltausgang", + "function_text": "Ausgang", + "description": "", + "device_address": "2.0.5", + "dpts": [ + { + "main": 1, + "sub": 1 + } + ], + "object_size": "1 Bit", + "flags": { + "read": true, + "write": false, + "communication": true, + "update": false, + "read_on_init": false, + "transmit": true + }, + "group_address_links": ["0/0/1"] + }, + "2.0.15/O-1_R-0": { + "name": "Time", + "number": 1, + "text": "Uhrzeit", + "function_text": "Senden", + "description": "", + "device_address": "2.0.15", + "dpts": [ + { + "main": 10, + "sub": 1 + } + ], + "object_size": "3 Bytes", + "flags": { + "read": false, + "write": false, + "communication": true, + "update": false, + "read_on_init": false, + "transmit": true + }, + "group_address_links": ["0/1/2"] + }, + "2.0.15/O-3_R-2": { + "name": "Trigger send date/time", + "number": 3, + "text": "Trigger sende Datum/Uhrzeit", + "function_text": "Empfangen", + "description": "", + "device_address": "2.0.15", + "dpts": [ + { + "main": 1, + "sub": 17 + } + ], + "object_size": "1 Bit", + "flags": { + "read": false, + "write": true, + "communication": true, + "update": false, + "read_on_init": false, + "transmit": false + }, + "group_address_links": ["0/1/0"] + } + }, + "topology": { + "0": { + "name": "Backbone Bereich", + "description": null, + "lines": { + "0": { + "name": "Bereichslinie", + "description": null, + "devices": [], + "medium_type": "KNXnet/IP (IP)" + } + } + }, + "1": { + "name": "Eins", + "description": null, + "lines": { + "0": { + "name": "Hauptlinie", + "description": null, + "devices": ["1.0.0", "1.0.9"], + "medium_type": "Twisted Pair (TP)" + }, + "1": { + "name": "L1", + "description": null, + "devices": ["1.1.0", "1.1.1", "1.1.6"], + "medium_type": "Twisted Pair (TP)" + } + } + }, + "2": { + "name": "Zwei", + "description": null, + "lines": { + "0": { + "name": "Hauptlinie", + "description": null, + "devices": ["2.0.0", "2.0.5", "2.0.6", "2.0.15"], + "medium_type": "Twisted Pair (TP)" + } + } + } + }, + "devices": { + "1.0.0": { + "name": "KNX IP Router 752 secure", + "hardware_name": "KNX IP Router 752 secure", + "description": "", + "manufacturer_name": "Weinzierl Engineering GmbH", + "individual_address": "1.0.0", + "project_uid": 6, + "communication_object_ids": [] + }, + "1.0.9": { + "name": "HCC/S2.2.1.1 Heiz-/Kühlkreis Controller,3-Punkt,2-fach,REG", + "hardware_name": "HCC/S2.2.1.1 Heiz-/Kühlkreis Controller,3-Punkt,2-fach,REG", + "description": "", + "manufacturer_name": "ABB", + "individual_address": "1.0.9", + "project_uid": 30, + "communication_object_ids": ["1.0.9/O-57_R-21", "1.0.9/O-73_R-29"] + }, + "1.1.0": { + "name": "Bereichs-/Linienkoppler REG", + "hardware_name": "Bereichs-/Linienkoppler REG", + "description": "", + "manufacturer_name": "Albrecht Jung", + "individual_address": "1.1.0", + "project_uid": 23, + "communication_object_ids": [] + }, + "1.1.1": { + "name": "SCN-IP000.03 IP Interface mit Secure", + "hardware_name": "IP Interface Secure", + "description": "", + "manufacturer_name": "MDT technologies", + "individual_address": "1.1.1", + "project_uid": 24, + "communication_object_ids": [] + }, + "1.1.6": { + "name": "Enertex KNX LED Dimmsequenzer 20A/5x REG", + "hardware_name": "LED Dimmsequenzer 20A/5x REG/DK", + "description": "", + "manufacturer_name": "Enertex Bayern GmbH", + "individual_address": "1.1.6", + "project_uid": 29, + "communication_object_ids": [ + "1.1.6/O-4_R-4", + "1.1.6/O-1_R-1", + "1.1.6/O-241_R-124" + ] + }, + "2.0.0": { + "name": "KNX/IP-Router", + "hardware_name": "IP Router", + "description": "", + "manufacturer_name": "GIRA Giersiepen", + "individual_address": "2.0.0", + "project_uid": 17, + "communication_object_ids": [] + }, + "2.0.5": { + "name": "Suntracer KNX pro", + "hardware_name": "KNX Suntracer Pro", + "description": "", + "manufacturer_name": "Elsner Elektronik GmbH", + "individual_address": "2.0.5", + "project_uid": 31, + "communication_object_ids": [ + "2.0.5/O-107_R-61", + "2.0.5/O-123_R-3923", + "2.0.5/O-331_R-254" + ] + }, + "2.0.6": { + "name": "KNX Modbus TCP Gateway 716", + "hardware_name": "KNX Modbus TCP Gateway 716", + "description": "", + "manufacturer_name": "Weinzierl Engineering GmbH", + "individual_address": "2.0.6", + "project_uid": 32, + "communication_object_ids": [] + }, + "2.0.15": { + "name": "KNX/IP-Router", + "hardware_name": "Router Applications", + "description": "", + "manufacturer_name": "GIRA Giersiepen", + "individual_address": "2.0.15", + "project_uid": 50, + "communication_object_ids": ["2.0.15/O-1_R-0", "2.0.15/O-3_R-2"] + } + }, + "group_addresses": { + "0/0/1": { + "name": "Binary", + "identifier": "GA-1", + "raw_address": 1, + "address": "0/0/1", + "project_uid": 43, + "dpt": { + "main": 1, + "sub": 1 + }, + "communication_object_ids": [ + "1.0.9/O-73_R-29", + "1.1.6/O-4_R-4", + "2.0.5/O-331_R-254" + ], + "description": "" + }, + "0/0/2": { + "name": "2-byte float", + "identifier": "GA-2", + "raw_address": 2, + "address": "0/0/2", + "project_uid": 44, + "dpt": { + "main": 9, + "sub": 1 + }, + "communication_object_ids": ["1.0.9/O-57_R-21", "2.0.5/O-123_R-3923"], + "description": "" + }, + "0/0/3": { + "name": "daytime", + "identifier": "GA-3", + "raw_address": 3, + "address": "0/0/3", + "project_uid": 45, + "dpt": { + "main": 10, + "sub": 1 + }, + "communication_object_ids": ["2.0.5/O-107_R-61"], + "description": "" + }, + "0/0/4": { + "name": "RGB color", + "identifier": "GA-7", + "raw_address": 4, + "address": "0/0/4", + "project_uid": 69, + "dpt": { + "main": 232, + "sub": 600 + }, + "communication_object_ids": [], + "description": "" + }, + "0/1/0": { + "name": "binary (1.017)", + "identifier": "GA-4", + "raw_address": 256, + "address": "0/1/0", + "project_uid": 47, + "dpt": { + "main": 1, + "sub": 17 + }, + "communication_object_ids": ["1.1.6/O-241_R-124", "2.0.15/O-3_R-2"], + "description": "" + }, + "0/1/1": { + "name": "percent", + "identifier": "GA-5", + "raw_address": 257, + "address": "0/1/1", + "project_uid": 48, + "dpt": { + "main": 5, + "sub": 1 + }, + "communication_object_ids": [], + "description": "" + }, + "0/1/2": { + "name": "daytime", + "identifier": "GA-6", + "raw_address": 258, + "address": "0/1/2", + "project_uid": 49, + "dpt": { + "main": 10, + "sub": 1 + }, + "communication_object_ids": ["1.1.6/O-1_R-1", "2.0.15/O-1_R-0"], + "description": "" + } + }, + "locations": { + "Neues Projekt": { + "type": "Building", + "identifier": "P-04FF-0_BP-1", + "name": "Neues Projekt", + "usage_id": null, + "usage_text": "", + "number": "", + "description": "", + "project_uid": 3, + "devices": [], + "spaces": {} + } + } +} diff --git a/tests/components/knx/test_diagnostic.py b/tests/components/knx/test_diagnostic.py index 99ce43998916..df8cb71d4af9 100644 --- a/tests/components/knx/test_diagnostic.py +++ b/tests/components/knx/test_diagnostic.py @@ -55,6 +55,7 @@ async def test_diagnostics( }, "configuration_error": None, "configuration_yaml": None, + "project_info": None, "xknx": {"current_address": "0.0.0", "version": "1.0.0"}, } @@ -85,6 +86,7 @@ async def test_diagnostic_config_error( }, "configuration_error": "extra keys not allowed @ data['knx']['wrong_key']", "configuration_yaml": {"wrong_key": {}}, + "project_info": None, "xknx": {"current_address": "0.0.0", "version": "1.0.0"}, } @@ -134,5 +136,34 @@ async def test_diagnostic_redact( }, "configuration_error": None, "configuration_yaml": None, + "project_info": None, "xknx": {"current_address": "0.0.0", "version": "1.0.0"}, } + + +@pytest.mark.parametrize("hass_config", [{}]) +async def test_diagnostics_project( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + knx: KNXTestKit, + mock_hass_config: None, + load_knxproj: None, +) -> None: + """Test diagnostics.""" + await knx.setup_integration({}) + diag = await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + + assert "config_entry_data" in diag + assert "configuration_error" in diag + assert "configuration_yaml" in diag + assert "project_info" in diag + assert "xknx" in diag + # project specific fields + assert "created_by" in diag["project_info"] + assert "group_address_style" in diag["project_info"] + assert "last_modified" in diag["project_info"] + assert "schema_version" in diag["project_info"] + assert "tool_version" in diag["project_info"] + assert "language_code" in diag["project_info"] + assert diag["project_info"]["name"] == "**REDACTED**" diff --git a/tests/components/knx/test_websocket.py b/tests/components/knx/test_websocket.py new file mode 100644 index 000000000000..c053e4fa9cbd --- /dev/null +++ b/tests/components/knx/test_websocket.py @@ -0,0 +1,282 @@ +"""KNX Websocket Tests.""" +from typing import Any +from unittest.mock import patch + +from homeassistant.components.knx import DOMAIN, KNX_ADDRESS, SwitchSchema +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant + +from .conftest import FIXTURE_PROJECT_DATA, KNXTestKit + +from tests.typing import WebSocketGenerator + + +async def test_knx_info_command( + hass: HomeAssistant, knx: KNXTestKit, hass_ws_client: WebSocketGenerator +): + """Test knx/info command.""" + await knx.setup_integration({}) + client = await hass_ws_client(hass) + await client.send_json({"id": 6, "type": "knx/info"}) + + res = await client.receive_json() + assert res["success"], res + assert res["result"]["version"] is not None + assert res["result"]["connected"] + assert res["result"]["current_address"] == "0.0.0" + assert res["result"]["project"] is None + + +async def test_knx_info_command_with_project( + hass: HomeAssistant, + knx: KNXTestKit, + hass_ws_client: WebSocketGenerator, + load_knxproj: None, +): + """Test knx/info command with loaded project.""" + await knx.setup_integration({}) + client = await hass_ws_client(hass) + await client.send_json({"id": 6, "type": "knx/info"}) + + res = await client.receive_json() + assert res["success"], res + assert res["result"]["version"] is not None + assert res["result"]["connected"] + assert res["result"]["current_address"] == "0.0.0" + assert res["result"]["project"] is not None + assert res["result"]["project"]["name"] == "Fixture" + assert res["result"]["project"]["last_modified"] == "2023-04-30T09:04:04.4043671Z" + assert res["result"]["project"]["tool_version"] == "5.7.1428.39779" + + +async def test_knx_project_file_process( + hass: HomeAssistant, + knx: KNXTestKit, + hass_ws_client: WebSocketGenerator, + hass_storage: dict[str, Any], +): + """Test knx/project_file_process command for storing and loading new data.""" + _file_id = "1234" + _password = "pw-test" + _parse_result = FIXTURE_PROJECT_DATA + + await knx.setup_integration({}) + client = await hass_ws_client(hass) + assert not hass.data[DOMAIN].project.loaded + + await client.send_json( + { + "id": 6, + "type": "knx/project_file_process", + "file_id": _file_id, + "password": _password, + } + ) + with patch( + "homeassistant.components.knx.project.process_uploaded_file", + ) as file_upload_mock, patch( + "xknxproject.XKNXProj.parse", return_value=_parse_result + ) as parse_mock: + file_upload_mock.return_value.__enter__.return_value = "" + res = await client.receive_json() + + file_upload_mock.assert_called_once_with(hass, _file_id) + parse_mock.assert_called_once_with() + + assert res["success"], res + assert hass.data[DOMAIN].project.loaded + + +async def test_knx_project_file_process_error( + hass: HomeAssistant, + knx: KNXTestKit, + hass_ws_client: WebSocketGenerator, +): + """Test knx/project_file_process exception handling.""" + await knx.setup_integration({}) + client = await hass_ws_client(hass) + assert not hass.data[DOMAIN].project.loaded + + await client.send_json( + { + "id": 6, + "type": "knx/project_file_process", + "file_id": "1234", + "password": "", + } + ) + with patch( + "homeassistant.components.knx.project.process_uploaded_file", + ) as file_upload_mock, patch( + "xknxproject.XKNXProj.parse", side_effect=ValueError + ) as parse_mock: + file_upload_mock.return_value.__enter__.return_value = "" + res = await client.receive_json() + parse_mock.assert_called_once_with() + + assert res["error"], res + assert not hass.data[DOMAIN].project.loaded + + +async def test_knx_project_file_remove( + hass: HomeAssistant, + knx: KNXTestKit, + hass_ws_client: WebSocketGenerator, + load_knxproj: None, +): + """Test knx/project_file_remove command.""" + await knx.setup_integration({}) + client = await hass_ws_client(hass) + assert hass.data[DOMAIN].project.loaded + + await client.send_json({"id": 6, "type": "knx/project_file_remove"}) + with patch("homeassistant.helpers.storage.Store.async_remove") as remove_mock: + res = await client.receive_json() + remove_mock.assert_called_once_with() + + assert res["success"], res + assert not hass.data[DOMAIN].project.loaded + + +async def test_knx_group_monitor_info_command( + hass: HomeAssistant, knx: KNXTestKit, hass_ws_client: WebSocketGenerator +): + """Test knx/group_monitor_info command.""" + await knx.setup_integration({}) + client = await hass_ws_client(hass) + + await client.send_json({"id": 6, "type": "knx/group_monitor_info"}) + + res = await client.receive_json() + assert res["success"], res + assert res["result"]["project_loaded"] is False + + +async def test_knx_subscribe_telegrams_command_no_project( + hass: HomeAssistant, knx: KNXTestKit, hass_ws_client: WebSocketGenerator +): + """Test knx/subscribe_telegrams command without project data.""" + await knx.setup_integration( + { + SwitchSchema.PLATFORM: { + CONF_NAME: "test", + KNX_ADDRESS: "1/2/4", + } + } + ) + client = await hass_ws_client(hass) + await client.send_json({"id": 6, "type": "knx/subscribe_telegrams"}) + res = await client.receive_json() + assert res["success"], res + + # send incoming events + await knx.receive_read("1/2/3") + await knx.receive_write("1/3/4", True) + await knx.receive_write("1/3/4", False) + await knx.receive_individual_address_read() + await knx.receive_write("1/3/8", (0x34, 0x45)) + # send outgoing events + await hass.services.async_call( + "switch", "turn_on", {"entity_id": "switch.test"}, blocking=True + ) + await knx.assert_write("1/2/4", 1) + + # receive events + res = await client.receive_json() + assert res["event"]["destination_address"] == "1/2/3" + assert res["event"]["payload"] == "" + assert res["event"]["type"] == "GroupValueRead" + assert res["event"]["source_address"] == "1.2.3" + assert res["event"]["direction"] == "group_monitor_incoming" + assert res["event"]["timestamp"] is not None + + res = await client.receive_json() + assert res["event"]["destination_address"] == "1/3/4" + assert res["event"]["payload"] == "1" + assert res["event"]["type"] == "GroupValueWrite" + assert res["event"]["source_address"] == "1.2.3" + assert res["event"]["direction"] == "group_monitor_incoming" + assert res["event"]["timestamp"] is not None + + res = await client.receive_json() + assert res["event"]["destination_address"] == "1/3/4" + assert res["event"]["payload"] == "0" + assert res["event"]["type"] == "GroupValueWrite" + assert res["event"]["source_address"] == "1.2.3" + assert res["event"]["direction"] == "group_monitor_incoming" + assert res["event"]["timestamp"] is not None + + res = await client.receive_json() + assert res["event"]["destination_address"] == "1/3/8" + assert res["event"]["payload"] == "0x3445" + assert res["event"]["type"] == "GroupValueWrite" + assert res["event"]["source_address"] == "1.2.3" + assert res["event"]["direction"] == "group_monitor_incoming" + assert res["event"]["timestamp"] is not None + + res = await client.receive_json() + assert res["event"]["destination_address"] == "1/2/4" + assert res["event"]["payload"] == "1" + assert res["event"]["type"] == "GroupValueWrite" + assert ( + res["event"]["source_address"] == "0.0.0" + ) # needs to be the IA currently connected to + assert res["event"]["direction"] == "group_monitor_outgoing" + assert res["event"]["timestamp"] is not None + + +async def test_knx_subscribe_telegrams_command_project( + hass: HomeAssistant, + knx: KNXTestKit, + hass_ws_client: WebSocketGenerator, + load_knxproj: None, +): + """Test knx/subscribe_telegrams command with project data.""" + await knx.setup_integration({}) + client = await hass_ws_client(hass) + await client.send_json({"id": 6, "type": "knx/subscribe_telegrams"}) + res = await client.receive_json() + assert res["success"], res + + # incoming DPT 1 telegram + await knx.receive_write("0/0/1", True) + res = await client.receive_json() + assert res["event"]["destination_address"] == "0/0/1" + assert res["event"]["destination_text"] == "Binary" + assert res["event"]["payload"] == "1" + assert res["event"]["type"] == "GroupValueWrite" + assert res["event"]["source_address"] == "1.2.3" + assert res["event"]["direction"] == "group_monitor_incoming" + assert res["event"]["timestamp"] is not None + + # incoming DPT 5 telegram + await knx.receive_write("0/1/1", (0x50,), source="1.1.6") + res = await client.receive_json() + assert res["event"]["destination_address"] == "0/1/1" + assert res["event"]["destination_text"] == "percent" + assert res["event"]["payload"] == "0x50" + assert res["event"]["value"] == "31 %" + assert res["event"]["type"] == "GroupValueWrite" + assert res["event"]["source_address"] == "1.1.6" + assert ( + res["event"]["source_text"] + == "Enertex Bayern GmbH Enertex KNX LED Dimmsequenzer 20A/5x REG" + ) + assert res["event"]["direction"] == "group_monitor_incoming" + assert res["event"]["timestamp"] is not None + + # incoming undecodable telegram (wrong payload type) + await knx.receive_write("0/1/1", True, source="1.1.6") + res = await client.receive_json() + assert res["event"]["destination_address"] == "0/1/1" + assert res["event"]["destination_text"] == "percent" + assert res["event"]["payload"] == "1" + assert res["event"]["value"] == "Error decoding value" + assert res["event"]["type"] == "GroupValueWrite" + assert res["event"]["source_address"] == "1.1.6" + assert ( + res["event"]["source_text"] + == "Enertex Bayern GmbH Enertex KNX LED Dimmsequenzer 20A/5x REG" + ) + assert res["event"]["direction"] == "group_monitor_incoming" + assert res["event"]["timestamp"] is not None From dd7db85529de0a88c5618c35a8f30cc455e830bf Mon Sep 17 00:00:00 2001 From: Ziv <16467659+ziv1234@users.noreply.github.com> Date: Thu, 11 May 2023 01:17:23 +0300 Subject: [PATCH 298/822] User config flow and custom panel for Dynalite integration (#77181) --- homeassistant/components/dynalite/__init__.py | 163 ++---------------- .../components/dynalite/config_flow.py | 57 +++++- .../components/dynalite/manifest.json | 4 +- homeassistant/components/dynalite/panel.py | 117 +++++++++++++ homeassistant/components/dynalite/schema.py | 155 +++++++++++++++++ .../components/dynalite/strings.json | 24 +++ requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/dynalite/test_config_flow.py | 87 +++++++++- tests/components/dynalite/test_panel.py | 141 +++++++++++++++ 10 files changed, 591 insertions(+), 163 deletions(-) create mode 100644 homeassistant/components/dynalite/panel.py create mode 100644 homeassistant/components/dynalite/schema.py create mode 100644 homeassistant/components/dynalite/strings.json create mode 100644 tests/components/dynalite/test_panel.py diff --git a/homeassistant/components/dynalite/__init__.py b/homeassistant/components/dynalite/__init__.py index fe1872e1fe33..c7f23af5fb1c 100644 --- a/homeassistant/components/dynalite/__init__.py +++ b/homeassistant/components/dynalite/__init__.py @@ -1,14 +1,11 @@ """Support for the Dynalite networks.""" from __future__ import annotations -from typing import Any - import voluptuous as vol from homeassistant import config_entries -from homeassistant.components.cover import DEVICE_CLASSES_SCHEMA from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_DEFAULT, CONF_HOST, CONF_NAME, CONF_PORT, CONF_TYPE +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv @@ -17,36 +14,10 @@ from homeassistant.helpers.typing import ConfigType # Loading the config flow file will register the flow from .bridge import DynaliteBridge from .const import ( - ACTIVE_INIT, - ACTIVE_OFF, - ACTIVE_ON, ATTR_AREA, ATTR_CHANNEL, ATTR_HOST, - CONF_ACTIVE, - CONF_AREA, - CONF_AUTO_DISCOVER, CONF_BRIDGES, - CONF_CHANNEL, - CONF_CHANNEL_COVER, - CONF_CLOSE_PRESET, - CONF_DEVICE_CLASS, - CONF_DURATION, - CONF_FADE, - CONF_LEVEL, - CONF_NO_DEFAULT, - CONF_OPEN_PRESET, - CONF_POLL_TIMER, - CONF_PRESET, - CONF_ROOM_OFF, - CONF_ROOM_ON, - CONF_STOP_PRESET, - CONF_TEMPLATE, - CONF_TILT_TIME, - DEFAULT_CHANNEL_TYPE, - DEFAULT_NAME, - DEFAULT_PORT, - DEFAULT_TEMPLATES, DOMAIN, LOGGER, PLATFORMS, @@ -54,128 +25,18 @@ from .const import ( SERVICE_REQUEST_CHANNEL_LEVEL, ) from .convert_config import convert_config - - -def num_string(value: int | str) -> str: - """Test if value is a string of digits, aka an integer.""" - new_value = str(value) - if new_value.isdigit(): - return new_value - raise vol.Invalid("Not a string with numbers") - - -CHANNEL_DATA_SCHEMA = vol.Schema( - { - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_FADE): vol.Coerce(float), - vol.Optional(CONF_TYPE, default=DEFAULT_CHANNEL_TYPE): vol.Any( - "light", "switch" - ), - } -) - -CHANNEL_SCHEMA = vol.Schema({num_string: CHANNEL_DATA_SCHEMA}) - -PRESET_DATA_SCHEMA = vol.Schema( - { - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_FADE): vol.Coerce(float), - vol.Optional(CONF_LEVEL): vol.Coerce(float), - } -) - -PRESET_SCHEMA = vol.Schema({num_string: vol.Any(PRESET_DATA_SCHEMA, None)}) - -TEMPLATE_ROOM_SCHEMA = vol.Schema( - {vol.Optional(CONF_ROOM_ON): num_string, vol.Optional(CONF_ROOM_OFF): num_string} -) - -TEMPLATE_TIMECOVER_SCHEMA = vol.Schema( - { - vol.Optional(CONF_CHANNEL_COVER): num_string, - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_OPEN_PRESET): num_string, - vol.Optional(CONF_CLOSE_PRESET): num_string, - vol.Optional(CONF_STOP_PRESET): num_string, - vol.Optional(CONF_DURATION): vol.Coerce(float), - vol.Optional(CONF_TILT_TIME): vol.Coerce(float), - } -) - -TEMPLATE_DATA_SCHEMA = vol.Any(TEMPLATE_ROOM_SCHEMA, TEMPLATE_TIMECOVER_SCHEMA) - -TEMPLATE_SCHEMA = vol.Schema({str: TEMPLATE_DATA_SCHEMA}) - - -def validate_area(config: dict[str, Any]) -> dict[str, Any]: - """Validate that template parameters are only used if area is using the relevant template.""" - conf_set = set() - for configs in DEFAULT_TEMPLATES.values(): - for conf in configs: - conf_set.add(conf) - if config.get(CONF_TEMPLATE): - for conf in DEFAULT_TEMPLATES[config[CONF_TEMPLATE]]: - conf_set.remove(conf) - for conf in conf_set: - if config.get(conf): - raise vol.Invalid( - f"{conf} should not be part of area {config[CONF_NAME]} config" - ) - return config - - -AREA_DATA_SCHEMA = vol.Schema( - vol.All( - { - vol.Required(CONF_NAME): cv.string, - vol.Optional(CONF_TEMPLATE): vol.In(DEFAULT_TEMPLATES), - vol.Optional(CONF_FADE): vol.Coerce(float), - vol.Optional(CONF_NO_DEFAULT): cv.boolean, - vol.Optional(CONF_CHANNEL): CHANNEL_SCHEMA, - vol.Optional(CONF_PRESET): PRESET_SCHEMA, - # the next ones can be part of the templates - vol.Optional(CONF_ROOM_ON): num_string, - vol.Optional(CONF_ROOM_OFF): num_string, - vol.Optional(CONF_CHANNEL_COVER): num_string, - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_OPEN_PRESET): num_string, - vol.Optional(CONF_CLOSE_PRESET): num_string, - vol.Optional(CONF_STOP_PRESET): num_string, - vol.Optional(CONF_DURATION): vol.Coerce(float), - vol.Optional(CONF_TILT_TIME): vol.Coerce(float), - }, - validate_area, - ) -) - -AREA_SCHEMA = vol.Schema({num_string: vol.Any(AREA_DATA_SCHEMA, None)}) - -PLATFORM_DEFAULTS_SCHEMA = vol.Schema({vol.Optional(CONF_FADE): vol.Coerce(float)}) - - -BRIDGE_SCHEMA = vol.Schema( - { - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): int, - vol.Optional(CONF_AUTO_DISCOVER, default=False): vol.Coerce(bool), - vol.Optional(CONF_POLL_TIMER, default=1.0): vol.Coerce(float), - vol.Optional(CONF_AREA): AREA_SCHEMA, - vol.Optional(CONF_DEFAULT): PLATFORM_DEFAULTS_SCHEMA, - vol.Optional(CONF_ACTIVE, default=False): vol.Any( - ACTIVE_ON, ACTIVE_OFF, ACTIVE_INIT, cv.boolean - ), - vol.Optional(CONF_PRESET): PRESET_SCHEMA, - vol.Optional(CONF_TEMPLATE): TEMPLATE_SCHEMA, - } -) +from .panel import async_register_dynalite_frontend +from .schema import BRIDGE_SCHEMA CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - {vol.Optional(CONF_BRIDGES): vol.All(cv.ensure_list, [BRIDGE_SCHEMA])} - ) - }, + vol.All( + cv.deprecated(DOMAIN), + { + DOMAIN: vol.Schema( + {vol.Optional(CONF_BRIDGES): vol.All(cv.ensure_list, [BRIDGE_SCHEMA])} + ), + }, + ), extra=vol.ALLOW_EXTRA, ) @@ -277,6 +138,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + await async_register_dynalite_frontend(hass) + return True diff --git a/homeassistant/components/dynalite/config_flow.py b/homeassistant/components/dynalite/config_flow.py index d723825319a6..946d4ac653d7 100644 --- a/homeassistant/components/dynalite/config_flow.py +++ b/homeassistant/components/dynalite/config_flow.py @@ -3,12 +3,16 @@ from __future__ import annotations from typing import Any +import voluptuous as vol + from homeassistant import config_entries -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from .bridge import DynaliteBridge -from .const import DOMAIN, LOGGER +from .const import DEFAULT_PORT, DOMAIN, LOGGER from .convert_config import convert_config @@ -23,8 +27,20 @@ class DynaliteFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_import(self, import_info: dict[str, Any]) -> FlowResult: """Import a new bridge as a config entry.""" - LOGGER.debug("Starting async_step_import - %s", import_info) + LOGGER.debug("Starting async_step_import (deprecated) - %s", import_info) + # Raise an issue that this is deprecated and has been imported + async_create_issue( + self.hass, + DOMAIN, + "deprecated_yaml", + is_fixable=False, + is_persistent=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + ) + host = import_info[CONF_HOST] + # Check if host already exists for entry in self._async_current_entries(): if entry.data[CONF_HOST] == host: self.hass.config_entries.async_update_entry( @@ -33,9 +49,34 @@ class DynaliteFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self.async_abort(reason="already_configured") # New entry - bridge = DynaliteBridge(self.hass, convert_config(import_info)) + return await self._try_create(import_info) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Step when user initializes a integration.""" + if user_input is not None: + return await self._try_create(user_input) + + schema = vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PORT, default=DEFAULT_PORT): int, + } + ) + return self.async_show_form(step_id="user", data_schema=schema) + + async def _try_create(self, info: dict[str, Any]) -> FlowResult: + """Try to connect and if successful, create entry.""" + host = info[CONF_HOST] + configured_hosts = [ + entry.data[CONF_HOST] for entry in self._async_current_entries() + ] + if host in configured_hosts: + return self.async_abort(reason="already_configured") + bridge = DynaliteBridge(self.hass, convert_config(info)) if not await bridge.async_setup(): - LOGGER.error("Unable to setup bridge - import info=%s", import_info) - return self.async_abort(reason="no_connection") - LOGGER.debug("Creating entry for the bridge - %s", import_info) - return self.async_create_entry(title=host, data=import_info) + LOGGER.error("Unable to setup bridge - import info=%s", info) + return self.async_abort(reason="cannot_connect") + LOGGER.debug("Creating entry for the bridge - %s", info) + return self.async_create_entry(title=info[CONF_HOST], data=info) diff --git a/homeassistant/components/dynalite/manifest.json b/homeassistant/components/dynalite/manifest.json index cded6a7e365a..a3dd890cc11f 100644 --- a/homeassistant/components/dynalite/manifest.json +++ b/homeassistant/components/dynalite/manifest.json @@ -1,10 +1,12 @@ { "domain": "dynalite", "name": "Philips Dynalite", + "after_dependencies": ["panel_custom"], "codeowners": ["@ziv1234"], "config_flow": true, + "dependencies": ["http", "websocket_api"], "documentation": "https://www.home-assistant.io/integrations/dynalite", "iot_class": "local_push", "loggers": ["dynalite_devices_lib"], - "requirements": ["dynalite_devices==0.1.47"] + "requirements": ["dynalite_devices==0.1.47", "dynalite_panel==0.0.4"] } diff --git a/homeassistant/components/dynalite/panel.py b/homeassistant/components/dynalite/panel.py new file mode 100644 index 000000000000..e7a0890033c3 --- /dev/null +++ b/homeassistant/components/dynalite/panel.py @@ -0,0 +1,117 @@ +"""Dynalite API interface for the frontend.""" + +from dynalite_panel import get_build_id, locate_dir +import voluptuous as vol + +from homeassistant.components import panel_custom, websocket_api +from homeassistant.components.cover import DEVICE_CLASSES +from homeassistant.const import CONF_DEFAULT, CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.core import HomeAssistant, callback + +from .const import ( + CONF_ACTIVE, + CONF_AREA, + CONF_AUTO_DISCOVER, + CONF_PRESET, + CONF_TEMPLATE, + DEFAULT_NAME, + DEFAULT_PORT, + DOMAIN, + LOGGER, +) +from .schema import BRIDGE_SCHEMA + +URL_BASE = "/dynalite_static" + +RELEVANT_CONFS = [ + CONF_NAME, + CONF_HOST, + CONF_PORT, + CONF_AUTO_DISCOVER, + CONF_AREA, + CONF_DEFAULT, + CONF_ACTIVE, + CONF_PRESET, + CONF_TEMPLATE, +] + + +@websocket_api.websocket_command( + { + vol.Required("type"): "dynalite/get-config", + } +) +@websocket_api.require_admin +@callback +def get_dynalite_config( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict +) -> None: + """Retrieve the Dynalite config for the frontend.""" + entries = hass.config_entries.async_entries(DOMAIN) + relevant_config = { + entry.entry_id: { + conf: entry.data[conf] for conf in RELEVANT_CONFS if conf in entry.data + } + for entry in entries + } + dynalite_defaults = { + "DEFAULT_NAME": DEFAULT_NAME, + "DEVICE_CLASSES": DEVICE_CLASSES, + "DEFAULT_PORT": DEFAULT_PORT, + } + connection.send_result( + msg["id"], {"config": relevant_config, "default": dynalite_defaults} + ) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "dynalite/save-config", + vol.Required("entry_id"): str, + vol.Required("config"): BRIDGE_SCHEMA, + } +) +@websocket_api.require_admin +@callback +def save_dynalite_config( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict +) -> None: + """Retrieve the Dynalite config for the frontend.""" + entry_id = msg["entry_id"] + entry = hass.config_entries.async_get_entry(entry_id) + if not entry: + LOGGER.error( + "Dynalite - received updated config for invalid entry - %s", entry_id + ) + connection.send_result(msg["id"], {"error": True}) + return + message_conf = msg["config"] + message_data = { + conf: message_conf[conf] for conf in RELEVANT_CONFS if conf in message_conf + } + LOGGER.info("Updating Dynalite config entry") + hass.config_entries.async_update_entry(entry, data=message_data) + connection.send_result(msg["id"], {}) + + +async def async_register_dynalite_frontend(hass: HomeAssistant): + """Register the Dynalite frontend configuration panel.""" + websocket_api.async_register_command(hass, get_dynalite_config) + websocket_api.async_register_command(hass, save_dynalite_config) + if DOMAIN not in hass.data.get("frontend_panels", {}): + path = locate_dir() + build_id = get_build_id() + hass.http.register_static_path( + URL_BASE, path, cache_headers=(build_id != "dev") + ) + + await panel_custom.async_register_panel( + hass=hass, + frontend_url_path=DOMAIN, + webcomponent_name="dynalite-panel", + sidebar_title=DOMAIN.capitalize(), + sidebar_icon="mdi:power", + module_url=f"{URL_BASE}/entrypoint-{build_id}.js", + embed_iframe=True, + require_admin=True, + ) diff --git a/homeassistant/components/dynalite/schema.py b/homeassistant/components/dynalite/schema.py new file mode 100644 index 000000000000..d470243782b4 --- /dev/null +++ b/homeassistant/components/dynalite/schema.py @@ -0,0 +1,155 @@ +"""Schema for config entries.""" + +from __future__ import annotations + +from typing import Any + +import voluptuous as vol + +from homeassistant.components.cover import DEVICE_CLASSES_SCHEMA +from homeassistant.const import CONF_DEFAULT, CONF_HOST, CONF_NAME, CONF_PORT, CONF_TYPE +from homeassistant.helpers import config_validation as cv + +from .const import ( + ACTIVE_INIT, + ACTIVE_OFF, + ACTIVE_ON, + CONF_ACTIVE, + CONF_AREA, + CONF_AUTO_DISCOVER, + CONF_CHANNEL, + CONF_CHANNEL_COVER, + CONF_CLOSE_PRESET, + CONF_DEVICE_CLASS, + CONF_DURATION, + CONF_FADE, + CONF_LEVEL, + CONF_NO_DEFAULT, + CONF_OPEN_PRESET, + CONF_POLL_TIMER, + CONF_PRESET, + CONF_ROOM_OFF, + CONF_ROOM_ON, + CONF_STOP_PRESET, + CONF_TEMPLATE, + CONF_TILT_TIME, + DEFAULT_CHANNEL_TYPE, + DEFAULT_NAME, + DEFAULT_PORT, + DEFAULT_TEMPLATES, +) + + +def num_string(value: str | int) -> str: + """Test if value is a string of digits, aka an integer.""" + new_value = str(value) + if new_value.isdigit(): + return new_value + raise vol.Invalid("Not a string with numbers") + + +CHANNEL_DATA_SCHEMA = vol.Schema( + { + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_FADE): vol.Coerce(float), + vol.Optional(CONF_TYPE, default=DEFAULT_CHANNEL_TYPE): vol.Any( + "light", "switch" + ), + } +) + +CHANNEL_SCHEMA = vol.Schema({num_string: CHANNEL_DATA_SCHEMA}) + +PRESET_DATA_SCHEMA = vol.Schema( + { + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_FADE): vol.Coerce(float), + vol.Optional(CONF_LEVEL): vol.Coerce(float), + } +) + +PRESET_SCHEMA = vol.Schema({num_string: vol.Any(PRESET_DATA_SCHEMA, None)}) + +TEMPLATE_ROOM_SCHEMA = vol.Schema( + {vol.Optional(CONF_ROOM_ON): num_string, vol.Optional(CONF_ROOM_OFF): num_string} +) + +TEMPLATE_TIMECOVER_SCHEMA = vol.Schema( + { + vol.Optional(CONF_CHANNEL_COVER): num_string, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_OPEN_PRESET): num_string, + vol.Optional(CONF_CLOSE_PRESET): num_string, + vol.Optional(CONF_STOP_PRESET): num_string, + vol.Optional(CONF_DURATION): vol.Coerce(float), + vol.Optional(CONF_TILT_TIME): vol.Coerce(float), + } +) + +TEMPLATE_DATA_SCHEMA = vol.Any(TEMPLATE_ROOM_SCHEMA, TEMPLATE_TIMECOVER_SCHEMA) + +TEMPLATE_SCHEMA = vol.Schema({str: TEMPLATE_DATA_SCHEMA}) + + +def validate_area(config: dict[str, Any]) -> dict[str, Any]: + """Validate that template parameters are only used if area is using the relevant template.""" + conf_set = set() + for configs in DEFAULT_TEMPLATES.values(): + for conf in configs: + conf_set.add(conf) + if config.get(CONF_TEMPLATE): + for conf in DEFAULT_TEMPLATES[config[CONF_TEMPLATE]]: + conf_set.remove(conf) + for conf in conf_set: + if config.get(conf): + raise vol.Invalid( + f"{conf} should not be part of area {config[CONF_NAME]} config" + ) + return config + + +AREA_DATA_SCHEMA = vol.Schema( + vol.All( + { + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_TEMPLATE): vol.In(DEFAULT_TEMPLATES), + vol.Optional(CONF_FADE): vol.Coerce(float), + vol.Optional(CONF_NO_DEFAULT): cv.boolean, + vol.Optional(CONF_CHANNEL): CHANNEL_SCHEMA, + vol.Optional(CONF_PRESET): PRESET_SCHEMA, + # the next ones can be part of the templates + vol.Optional(CONF_ROOM_ON): num_string, + vol.Optional(CONF_ROOM_OFF): num_string, + vol.Optional(CONF_CHANNEL_COVER): num_string, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_OPEN_PRESET): num_string, + vol.Optional(CONF_CLOSE_PRESET): num_string, + vol.Optional(CONF_STOP_PRESET): num_string, + vol.Optional(CONF_DURATION): vol.Coerce(float), + vol.Optional(CONF_TILT_TIME): vol.Coerce(float), + }, + validate_area, + ) +) + +AREA_SCHEMA = vol.Schema({num_string: vol.Any(AREA_DATA_SCHEMA, None)}) + +PLATFORM_DEFAULTS_SCHEMA = vol.Schema({vol.Optional(CONF_FADE): vol.Coerce(float)}) + + +BRIDGE_SCHEMA = vol.Schema( + { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): int, + vol.Optional(CONF_AUTO_DISCOVER, default=False): vol.Coerce(bool), + vol.Optional(CONF_POLL_TIMER, default=1.0): vol.Coerce(float), + vol.Optional(CONF_AREA): AREA_SCHEMA, + vol.Optional(CONF_DEFAULT): PLATFORM_DEFAULTS_SCHEMA, + vol.Optional(CONF_ACTIVE, default=False): vol.Any( + ACTIVE_ON, ACTIVE_OFF, ACTIVE_INIT, cv.boolean + ), + vol.Optional(CONF_PRESET): PRESET_SCHEMA, + vol.Optional(CONF_TEMPLATE): TEMPLATE_SCHEMA, + } +) diff --git a/homeassistant/components/dynalite/strings.json b/homeassistant/components/dynalite/strings.json new file mode 100644 index 000000000000..1d78108f909f --- /dev/null +++ b/homeassistant/components/dynalite/strings.json @@ -0,0 +1,24 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" + }, + "title": "Configure Dynalite Connection", + "description": "Gateway address to connect to DYNET network" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + } + }, + "issues": { + "deprecated_yaml": { + "title": "The Dynalite YAML configuration is being removed", + "description": "Configuring Dynalite using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the Dynalite YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + } + } +} diff --git a/requirements_all.txt b/requirements_all.txt index badce2c5bd7b..7d1f2c7328f4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -625,6 +625,9 @@ dweepy==0.3.0 # homeassistant.components.dynalite dynalite_devices==0.1.47 +# homeassistant.components.dynalite +dynalite_panel==0.0.4 + # homeassistant.components.rainforest_eagle eagle100==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6ae9df6d7558..e69f742d4cca 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -499,6 +499,9 @@ dwdwfsapi==1.0.6 # homeassistant.components.dynalite dynalite_devices==0.1.47 +# homeassistant.components.dynalite +dynalite_panel==0.0.4 + # homeassistant.components.rainforest_eagle eagle100==0.1.1 diff --git a/tests/components/dynalite/test_config_flow.py b/tests/components/dynalite/test_config_flow.py index c2b0cc1a7d77..d0bd335decc5 100644 --- a/tests/components/dynalite/test_config_flow.py +++ b/tests/components/dynalite/test_config_flow.py @@ -5,7 +5,13 @@ import pytest from homeassistant import config_entries from homeassistant.components import dynalite +from homeassistant.const import CONF_PORT from homeassistant.core import HomeAssistant +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_get as async_get_issue_registry, +) +from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -14,14 +20,22 @@ from tests.common import MockConfigEntry ("first_con", "second_con", "exp_type", "exp_result", "exp_reason"), [ (True, True, "create_entry", config_entries.ConfigEntryState.LOADED, ""), - (False, False, "abort", None, "no_connection"), + (False, False, "abort", None, "cannot_connect"), (True, False, "create_entry", config_entries.ConfigEntryState.SETUP_RETRY, ""), ], ) async def test_flow( - hass: HomeAssistant, first_con, second_con, exp_type, exp_result, exp_reason + hass: HomeAssistant, + first_con, + second_con, + exp_type, + exp_result, + exp_reason, ) -> None: """Run a flow with or without errors and return result.""" + registry = async_get_issue_registry(hass) + issue = registry.async_get_issue(dynalite.DOMAIN, "deprecated_yaml") + assert issue is None host = "1.2.3.4" with patch( "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup", @@ -38,6 +52,19 @@ async def test_flow( assert result["result"].state == exp_result if exp_reason: assert result["reason"] == exp_reason + issue = registry.async_get_issue(dynalite.DOMAIN, "deprecated_yaml") + assert issue is not None + assert issue.severity == IssueSeverity.WARNING + + +async def test_deprecated( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Check that deprecation warning appears in caplog.""" + await async_setup_component( + hass, dynalite.DOMAIN, {dynalite.DOMAIN: {dynalite.CONF_HOST: "aaa"}} + ) + assert "The 'dynalite' option is deprecated" in caplog.text async def test_existing(hass: HomeAssistant) -> None: @@ -66,7 +93,7 @@ async def test_existing_update(hass: HomeAssistant) -> None: port2 = 8888 entry = MockConfigEntry( domain=dynalite.DOMAIN, - data={dynalite.CONF_HOST: host, dynalite.CONF_PORT: port1}, + data={dynalite.CONF_HOST: host, CONF_PORT: port1}, ) entry.add_to_hass(hass) with patch( @@ -80,7 +107,7 @@ async def test_existing_update(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( dynalite.DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, - data={dynalite.CONF_HOST: host, dynalite.CONF_PORT: port2}, + data={dynalite.CONF_HOST: host, CONF_PORT: port2}, ) await hass.async_block_till_done() assert mock_dyn_dev().configure.call_count == 2 @@ -107,3 +134,55 @@ async def test_two_entries(hass: HomeAssistant) -> None: ) assert result["type"] == "create_entry" assert result["result"].state == config_entries.ConfigEntryState.LOADED + + +async def test_setup_user(hass): + """Test configuration via the user flow.""" + host = "3.4.5.6" + port = 1234 + result = await hass.config_entries.flow.async_init( + dynalite.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"] is None + + with patch( + "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"host": host, "port": port}, + ) + + assert result["type"] == "create_entry" + assert result["result"].state == config_entries.ConfigEntryState.LOADED + assert result["title"] == host + assert result["data"] == { + "host": host, + "port": port, + } + + +async def test_setup_user_existing_host(hass): + """Test that when we setup a host that is defined, we get an error.""" + host = "3.4.5.6" + MockConfigEntry( + domain=dynalite.DOMAIN, data={dynalite.CONF_HOST: host} + ).add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + dynalite.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + with patch( + "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"host": host, "port": 1234}, + ) + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" diff --git a/tests/components/dynalite/test_panel.py b/tests/components/dynalite/test_panel.py new file mode 100644 index 000000000000..a0acad545511 --- /dev/null +++ b/tests/components/dynalite/test_panel.py @@ -0,0 +1,141 @@ +"""Test websocket commands for the panel.""" + + +from unittest.mock import patch + +from homeassistant.components import dynalite +from homeassistant.components.cover import DEVICE_CLASSES +from homeassistant.const import CONF_PORT + +from tests.common import MockConfigEntry + + +async def test_get_config(hass, hass_ws_client): + """Get the config via websocket.""" + host = "1.2.3.4" + port = 765 + + entry = MockConfigEntry( + domain=dynalite.DOMAIN, + data={dynalite.CONF_HOST: host, CONF_PORT: port}, + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup", + return_value=True, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + + await client.send_json( + { + "id": 24, + "type": "dynalite/get-config", + } + ) + + msg = await client.receive_json() + assert msg["success"] + result = msg["result"] + entry_id = entry.entry_id + assert result == { + "config": {entry_id: {dynalite.CONF_HOST: host, CONF_PORT: port}}, + "default": { + "DEFAULT_NAME": dynalite.const.DEFAULT_NAME, + "DEFAULT_PORT": dynalite.const.DEFAULT_PORT, + "DEVICE_CLASSES": DEVICE_CLASSES, + }, + } + + +async def test_save_config(hass, hass_ws_client): + """Save the config via websocket.""" + host1 = "1.2.3.4" + port1 = 765 + host2 = "5.6.7.8" + port2 = 432 + host3 = "5.3.2.1" + port3 = 543 + + entry1 = MockConfigEntry( + domain=dynalite.DOMAIN, + data={dynalite.CONF_HOST: host1, CONF_PORT: port1}, + ) + entry1.add_to_hass(hass) + with patch( + "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup", + return_value=True, + ): + assert await hass.config_entries.async_setup(entry1.entry_id) + await hass.async_block_till_done() + entry2 = MockConfigEntry( + domain=dynalite.DOMAIN, + data={dynalite.CONF_HOST: host2, CONF_PORT: port2}, + ) + entry2.add_to_hass(hass) + with patch( + "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup", + return_value=True, + ): + assert await hass.config_entries.async_setup(entry2.entry_id) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + + await client.send_json( + { + "id": 24, + "type": "dynalite/save-config", + "entry_id": entry2.entry_id, + "config": {dynalite.CONF_HOST: host3, CONF_PORT: port3}, + } + ) + + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == {} + + existing_entry = hass.config_entries.async_get_entry(entry1.entry_id) + assert existing_entry.data == {dynalite.CONF_HOST: host1, CONF_PORT: port1} + modified_entry = hass.config_entries.async_get_entry(entry2.entry_id) + assert modified_entry.data[dynalite.CONF_HOST] == host3 + assert modified_entry.data[CONF_PORT] == port3 + + +async def test_save_config_invalid_entry(hass, hass_ws_client): + """Try to update nonexistent entry.""" + host1 = "1.2.3.4" + port1 = 765 + host2 = "5.6.7.8" + port2 = 432 + + entry = MockConfigEntry( + domain=dynalite.DOMAIN, + data={dynalite.CONF_HOST: host1, CONF_PORT: port1}, + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup", + return_value=True, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + await client.send_json( + { + "id": 24, + "type": "dynalite/save-config", + "entry_id": "junk", + "config": {dynalite.CONF_HOST: host2, CONF_PORT: port2}, + } + ) + + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == {"error": True} + + existing_entry = hass.config_entries.async_get_entry(entry.entry_id) + assert existing_entry.data == {dynalite.CONF_HOST: host1, CONF_PORT: port1} From 14752fdfc25a1626041982d96755a52ebbdc0b12 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Wed, 10 May 2023 20:32:14 -0400 Subject: [PATCH 299/822] Bump eternalegypt to 0.0.16 (#92919) --- CODEOWNERS | 1 + homeassistant/components/netgear_lte/manifest.json | 4 ++-- requirements_all.txt | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 9468ef67619f..5aef36e2b950 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -788,6 +788,7 @@ build.json @home-assistant/supervisor /homeassistant/components/netdata/ @fabaff /homeassistant/components/netgear/ @hacf-fr @Quentame @starkillerOG /tests/components/netgear/ @hacf-fr @Quentame @starkillerOG +/homeassistant/components/netgear_lte/ @tkdrob /homeassistant/components/network/ @home-assistant/core /tests/components/network/ @home-assistant/core /homeassistant/components/nexia/ @bdraco diff --git a/homeassistant/components/netgear_lte/manifest.json b/homeassistant/components/netgear_lte/manifest.json index 427aa9633c87..c9a5245da415 100644 --- a/homeassistant/components/netgear_lte/manifest.json +++ b/homeassistant/components/netgear_lte/manifest.json @@ -1,9 +1,9 @@ { "domain": "netgear_lte", "name": "NETGEAR LTE", - "codeowners": [], + "codeowners": ["@tkdrob"], "documentation": "https://www.home-assistant.io/integrations/netgear_lte", "iot_class": "local_polling", "loggers": ["eternalegypt"], - "requirements": ["eternalegypt==0.0.15"] + "requirements": ["eternalegypt==0.0.16"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7d1f2c7328f4..fc2f5d7cc73d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -686,7 +686,7 @@ epsonprinter==0.0.9 esphome-dashboard-api==1.2.3 # homeassistant.components.netgear_lte -eternalegypt==0.0.15 +eternalegypt==0.0.16 # homeassistant.components.eufylife_ble eufylife_ble_client==0.1.7 From 2ea21e9c2ad2d01d27e869c0555fa92acb724a74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A5le=20Stor=C3=B8=20Hauknes?= Date: Thu, 11 May 2023 08:31:23 +0200 Subject: [PATCH 300/822] Adjust link for Airthings (cloud) devices (#92922) --- homeassistant/components/airthings/sensor.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/airthings/sensor.py b/homeassistant/components/airthings/sensor.py index 513c589dc774..7a124ebbc808 100644 --- a/homeassistant/components/airthings/sensor.py +++ b/homeassistant/components/airthings/sensor.py @@ -150,7 +150,10 @@ class AirthingsHeaterEnergySensor(CoordinatorEntity, SensorEntity): self._attr_unique_id = f"{airthings_device.device_id}_{entity_description.key}" self._id = airthings_device.device_id self._attr_device_info = DeviceInfo( - configuration_url="https://dashboard.airthings.com/", + configuration_url=( + "https://dashboard.airthings.com/devices/" + f"{airthings_device.device_id}" + ), identifiers={(DOMAIN, airthings_device.device_id)}, name=airthings_device.name, manufacturer="Airthings", From 3c8659d5d7d7543c7cb6a3bf0e92a68fb5ec998c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 May 2023 15:37:15 +0900 Subject: [PATCH 301/822] Fix missing f-string in async_track_utc_time_change (#92916) --- 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 0a51d6660ae8..38d811c2afd7 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -1603,7 +1603,7 @@ def async_track_utc_time_change( pattern_time_change_listener_job = HassJob( pattern_time_change_listener, - "time change listener {hour}:{minute}:{second} {action}", + f"time change listener {hour}:{minute}:{second} {action}", ) time_listener = async_track_point_in_utc_time( hass, pattern_time_change_listener_job, calculate_next(dt_util.utcnow()) From 2b59adc955001ae744cd6e557cbc16f0f3261d2a Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 11 May 2023 08:38:08 +0200 Subject: [PATCH 302/822] Update mypy to 1.3.0 (#92911) --- homeassistant/components/wemo/fan.py | 1 + requirements_test.txt | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/wemo/fan.py b/homeassistant/components/wemo/fan.py index 81065cf8108d..1d2c2c9252dc 100644 --- a/homeassistant/components/wemo/fan.py +++ b/homeassistant/components/wemo/fan.py @@ -86,6 +86,7 @@ class WemoHumidifier(WemoBinaryStateEntity, FanEntity): _attr_supported_features = FanEntityFeature.SET_SPEED wemo: Humidifier + _last_fan_on_mode: FanMode def __init__(self, coordinator: DeviceCoordinator) -> None: """Initialize the WeMo switch.""" diff --git a/requirements_test.txt b/requirements_test.txt index 2b0cf62bbfdc..54086086c388 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -11,7 +11,7 @@ astroid==2.15.4 coverage==7.2.4 freezegun==1.2.2 mock-open==1.4.0 -mypy==1.2.0 +mypy==1.3.0 pre-commit==3.1.0 pydantic==1.10.7 pylint==2.17.4 From fbd06e07451743018dacdedcf4b6744bd64f3913 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A5le=20Stor=C3=B8=20Hauknes?= Date: Thu, 11 May 2023 08:44:38 +0200 Subject: [PATCH 303/822] Add model name to Airthings integration (#92921) --- homeassistant/components/airthings/sensor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/airthings/sensor.py b/homeassistant/components/airthings/sensor.py index 7a124ebbc808..5212ff51fe88 100644 --- a/homeassistant/components/airthings/sensor.py +++ b/homeassistant/components/airthings/sensor.py @@ -157,6 +157,7 @@ class AirthingsHeaterEnergySensor(CoordinatorEntity, SensorEntity): identifiers={(DOMAIN, airthings_device.device_id)}, name=airthings_device.name, manufacturer="Airthings", + model=airthings_device.device_type.replace("_", " ").lower().title(), ) @property From 2c79adad5e67b16574885e4ae05b407da8553abd Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 11 May 2023 07:54:51 +0100 Subject: [PATCH 304/822] Fix lingering timer in flux (#92573) --- homeassistant/components/flux/switch.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/homeassistant/components/flux/switch.py b/homeassistant/components/flux/switch.py index d8a58192593a..f71ccc87f056 100644 --- a/homeassistant/components/flux/switch.py +++ b/homeassistant/components/flux/switch.py @@ -226,6 +226,12 @@ class FluxSwitch(SwitchEntity, RestoreEntity): if last_state and last_state.state == STATE_ON: await self.async_turn_on() + async def async_will_remove_from_hass(self) -> None: + """Run when entity will be removed from hass.""" + if self.unsub_tracker: + self.unsub_tracker() + return await super().async_will_remove_from_hass() + async def async_turn_on(self, **kwargs: Any) -> None: """Turn on flux.""" if self.is_on: From 0cc3e8200c8016e6a9a80f9eaf847a178faf17ec Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 11 May 2023 07:55:36 +0100 Subject: [PATCH 305/822] Fix lingering timer in iaqualink (#92856) --- homeassistant/components/iaqualink/__init__.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/iaqualink/__init__.py b/homeassistant/components/iaqualink/__init__.py index 225953035a2a..e0b381d2362d 100644 --- a/homeassistant/components/iaqualink/__init__.py +++ b/homeassistant/components/iaqualink/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio from collections.abc import Awaitable, Callable, Coroutine +from datetime import datetime from functools import wraps import logging from typing import Any, Concatenate, ParamSpec, TypeVar @@ -139,7 +140,7 @@ async def async_setup_entry( # noqa: C901 await hass.config_entries.async_forward_entry_setups(entry, platforms) - async def _async_systems_update(now): + async def _async_systems_update(_: datetime) -> None: """Refresh internal state for all systems.""" for system in systems: prev = system.online @@ -161,7 +162,9 @@ async def async_setup_entry( # noqa: C901 async_dispatcher_send(hass, DOMAIN) - async_track_time_interval(hass, _async_systems_update, UPDATE_INTERVAL) + entry.async_on_unload( + async_track_time_interval(hass, _async_systems_update, UPDATE_INTERVAL) + ) return True From bf2cfb8edf71c5a11af56be4ffabc97d6a8c3b77 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 May 2023 15:58:29 +0900 Subject: [PATCH 306/822] Bump bluetooth-auto-recovery to 1.2.0 (#92893) --- 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 dd384e80c1ed..e7f95a2b63a3 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -18,7 +18,7 @@ "bleak==0.20.2", "bleak-retry-connector==3.0.2", "bluetooth-adapters==0.15.3", - "bluetooth-auto-recovery==1.1.2", + "bluetooth-auto-recovery==1.2.0", "bluetooth-data-tools==0.4.0", "dbus-fast==1.86.0" ] diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 3384bc4f6519..79898ec89b93 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -14,7 +14,7 @@ bcrypt==4.0.1 bleak-retry-connector==3.0.2 bleak==0.20.2 bluetooth-adapters==0.15.3 -bluetooth-auto-recovery==1.1.2 +bluetooth-auto-recovery==1.2.0 bluetooth-data-tools==0.4.0 certifi>=2021.5.30 ciso8601==2.3.0 diff --git a/requirements_all.txt b/requirements_all.txt index fc2f5d7cc73d..8f8dc29ba699 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -465,7 +465,7 @@ bluemaestro-ble==0.2.3 bluetooth-adapters==0.15.3 # homeassistant.components.bluetooth -bluetooth-auto-recovery==1.1.2 +bluetooth-auto-recovery==1.2.0 # homeassistant.components.bluetooth # homeassistant.components.esphome diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e69f742d4cca..3bba3019e753 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -385,7 +385,7 @@ bluemaestro-ble==0.2.3 bluetooth-adapters==0.15.3 # homeassistant.components.bluetooth -bluetooth-auto-recovery==1.1.2 +bluetooth-auto-recovery==1.2.0 # homeassistant.components.bluetooth # homeassistant.components.esphome From 26f7843800556fac3ec22a8216d9fe293c96a955 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Thu, 11 May 2023 09:10:06 +0200 Subject: [PATCH 307/822] Fix uptime sensor deviation detection in Fritz!Tools (#92907) --- homeassistant/components/fritz/common.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/fritz/common.py b/homeassistant/components/fritz/common.py index 818030965f95..26b336208fef 100644 --- a/homeassistant/components/fritz/common.py +++ b/homeassistant/components/fritz/common.py @@ -283,7 +283,7 @@ class FritzBoxTools( entity_data["entity_states"][ key ] = await self.hass.async_add_executor_job( - update_fn, self.fritz_status, self.data.get(key) + update_fn, self.fritz_status, self.data["entity_states"].get(key) ) if self.has_call_deflections: entity_data[ From 949e8f7b1380dbb92c83e1cf5cc2a86dd7b3066a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 11 May 2023 08:26:16 +0100 Subject: [PATCH 308/822] Migrate rest notify to httpx (#90769) --- homeassistant/components/rest/notify.py | 46 ++++++++++++++----------- 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/rest/notify.py b/homeassistant/components/rest/notify.py index f1d4152b4b48..e155fe47048c 100644 --- a/homeassistant/components/rest/notify.py +++ b/homeassistant/components/rest/notify.py @@ -5,8 +5,7 @@ from http import HTTPStatus import logging from typing import Any -import requests -from requests.auth import AuthBase, HTTPBasicAuth, HTTPDigestAuth +import httpx import voluptuous as vol from homeassistant.components.notify import ( @@ -32,6 +31,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -72,7 +72,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( _LOGGER = logging.getLogger(__name__) -def get_service( +async def async_get_service( hass: HomeAssistant, config: ConfigType, discovery_info: DiscoveryInfoType | None = None, @@ -91,12 +91,12 @@ def get_service( password: str | None = config.get(CONF_PASSWORD) verify_ssl: bool = config[CONF_VERIFY_SSL] - auth: AuthBase | None = None + auth: httpx.Auth | None = None if username and password: if config.get(CONF_AUTHENTICATION) == HTTP_DIGEST_AUTHENTICATION: - auth = HTTPDigestAuth(username, password) + auth = httpx.DigestAuth(username, password) else: - auth = HTTPBasicAuth(username, password) + auth = httpx.BasicAuth(username, password) return RestNotificationService( hass, @@ -129,7 +129,7 @@ class RestNotificationService(BaseNotificationService): target_param_name: str | None, data: dict[str, Any] | None, data_template: dict[str, Any] | None, - auth: AuthBase | None, + auth: httpx.Auth | None, verify_ssl: bool, ) -> None: """Initialize the service.""" @@ -146,7 +146,7 @@ class RestNotificationService(BaseNotificationService): self._auth = auth self._verify_ssl = verify_ssl - def send_message(self, message: str = "", **kwargs: Any) -> None: + async def async_send_message(self, message: str = "", **kwargs: Any) -> None: """Send a message to a user.""" data = {self._message_param_name: message} @@ -179,34 +179,32 @@ class RestNotificationService(BaseNotificationService): if self._data_template: data.update(_data_template_creator(self._data_template)) + websession = get_async_client(self._hass, self._verify_ssl) if self._method == "POST": - response = requests.post( + response = await websession.post( self._resource, headers=self._headers, params=self._params, data=data, timeout=10, - auth=self._auth, - verify=self._verify_ssl, + auth=self._auth or httpx.USE_CLIENT_DEFAULT, ) elif self._method == "POST_JSON": - response = requests.post( + response = await websession.post( self._resource, headers=self._headers, params=self._params, json=data, timeout=10, - auth=self._auth, - verify=self._verify_ssl, + auth=self._auth or httpx.USE_CLIENT_DEFAULT, ) else: # default GET - response = requests.get( + response = await websession.get( self._resource, headers=self._headers, params={**self._params, **data} if self._params else data, timeout=10, auth=self._auth, - verify=self._verify_ssl, ) if ( @@ -214,21 +212,29 @@ class RestNotificationService(BaseNotificationService): and response.status_code < 600 ): _LOGGER.exception( - "Server error. Response %d: %s:", response.status_code, response.reason + "Server error. Response %d: %s:", + response.status_code, + response.reason_phrase, ) elif ( response.status_code >= HTTPStatus.BAD_REQUEST and response.status_code < HTTPStatus.INTERNAL_SERVER_ERROR ): _LOGGER.exception( - "Client error. Response %d: %s:", response.status_code, response.reason + "Client error. Response %d: %s:", + response.status_code, + response.reason_phrase, ) elif ( response.status_code >= HTTPStatus.OK and response.status_code < HTTPStatus.MULTIPLE_CHOICES ): _LOGGER.debug( - "Success. Response %d: %s:", response.status_code, response.reason + "Success. Response %d: %s:", + response.status_code, + response.reason_phrase, ) else: - _LOGGER.debug("Response %d: %s:", response.status_code, response.reason) + _LOGGER.debug( + "Response %d: %s:", response.status_code, response.reason_phrase + ) From c14efef5822dbaf22d5a3efe41ddf87924834f03 Mon Sep 17 00:00:00 2001 From: Jonathan Keslin Date: Thu, 11 May 2023 00:42:04 -0700 Subject: [PATCH 309/822] Bump volvooncall to 0.10.3 to fix sensor type error (#92913) --- homeassistant/components/volvooncall/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/volvooncall/manifest.json b/homeassistant/components/volvooncall/manifest.json index 99553426ea87..89a35ecde1d7 100644 --- a/homeassistant/components/volvooncall/manifest.json +++ b/homeassistant/components/volvooncall/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/volvooncall", "iot_class": "cloud_polling", "loggers": ["geopy", "hbmqtt", "volvooncall"], - "requirements": ["volvooncall==0.10.2"] + "requirements": ["volvooncall==0.10.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8f8dc29ba699..db36062ce8bd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2600,7 +2600,7 @@ voip-utils==0.0.7 volkszaehler==0.4.0 # homeassistant.components.volvooncall -volvooncall==0.10.2 +volvooncall==0.10.3 # homeassistant.components.verisure vsure==2.6.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3bba3019e753..b1632f507b53 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1885,7 +1885,7 @@ vilfo-api-client==0.3.2 voip-utils==0.0.7 # homeassistant.components.volvooncall -volvooncall==0.10.2 +volvooncall==0.10.3 # homeassistant.components.verisure vsure==2.6.1 From 8b57d31eba10c285f8afb47173b37d6064754e14 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 May 2023 17:03:31 +0900 Subject: [PATCH 310/822] Add ONVIF services to diagnostics (#92878) --- homeassistant/components/onvif/diagnostics.py | 4 ++++ tests/components/onvif/__init__.py | 2 ++ tests/components/onvif/test_diagnostics.py | 4 ++++ 3 files changed, 10 insertions(+) diff --git a/homeassistant/components/onvif/diagnostics.py b/homeassistant/components/onvif/diagnostics.py index d7f2c5153089..a802aed5e80e 100644 --- a/homeassistant/components/onvif/diagnostics.py +++ b/homeassistant/components/onvif/diagnostics.py @@ -27,6 +27,10 @@ async def async_get_config_entry_diagnostics( "info": asdict(device.info), "capabilities": asdict(device.capabilities), "profiles": [asdict(profile) for profile in device.profiles], + "services": { + str(key): service.url for key, service in device.device.services.items() + }, + "xaddrs": device.device.xaddrs, } data["events"] = { "webhook_manager_state": device.events.webhook_manager.state, diff --git a/tests/components/onvif/__init__.py b/tests/components/onvif/__init__.py index a56e0a477e7a..598546a64176 100644 --- a/tests/components/onvif/__init__.py +++ b/tests/components/onvif/__init__.py @@ -101,6 +101,8 @@ def setup_mock_onvif_camera( mock_onvif_camera.create_devicemgmt_service = AsyncMock(return_value=devicemgmt) mock_onvif_camera.create_media_service = AsyncMock(return_value=media_service) mock_onvif_camera.close = AsyncMock(return_value=None) + mock_onvif_camera.xaddrs = {} + mock_onvif_camera.services = {} def mock_constructor( host, diff --git a/tests/components/onvif/test_diagnostics.py b/tests/components/onvif/test_diagnostics.py index 70dafe960b44..2ab2deb6884f 100644 --- a/tests/components/onvif/test_diagnostics.py +++ b/tests/components/onvif/test_diagnostics.py @@ -1,4 +1,6 @@ """Test ONVIF diagnostics.""" +from unittest.mock import ANY + from homeassistant.core import HomeAssistant from . import ( @@ -71,6 +73,8 @@ async def test_diagnostics( "video_source_token": None, } ], + "services": ANY, + "xaddrs": ANY, }, "events": { "pullpoint_manager_state": { From 4568207f9bcc915b5d5ce92e900a0c62be67c688 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 11 May 2023 10:05:58 +0200 Subject: [PATCH 311/822] Support calculating changes between consecutive sum statistics (#92823) * Support calculating changes between consecutive sum statistics * Add support for unit conversion when calculating change * Don't include sum in WS response unless requested * Improve tests * Break out calculating change to its own function * Improve test coverage --- .../components/recorder/statistics.py | 97 +- .../components/recorder/websocket_api.py | 6 +- tests/components/recorder/test_statistics.py | 826 +++++++++++++++++- .../components/recorder/test_websocket_api.py | 2 + tests/components/sensor/test_recorder.py | 8 +- 5 files changed, 863 insertions(+), 76 deletions(-) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 0e49c09e17ce..2ef790024fdb 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -165,6 +165,7 @@ class StatisticsRow(BaseStatisticsRow, total=False): min: float | None max: float | None mean: float | None + change: float | None def _get_unit_class(unit: str | None) -> str | None: @@ -1540,6 +1541,57 @@ def _extract_metadata_and_discard_impossible_columns( return metadata_ids +def _augment_result_with_change( + hass: HomeAssistant, + session: Session, + start_time: datetime, + units: dict[str, str] | None, + _types: set[Literal["change", "last_reset", "max", "mean", "min", "state", "sum"]], + table: type[Statistics | StatisticsShortTerm], + metadata: dict[str, tuple[int, StatisticMetaData]], + result: dict[str, list[StatisticsRow]], +) -> None: + """Add change to the result.""" + drop_sum = "sum" not in _types + prev_sums = {} + if tmp := _statistics_at_time( + session, + {metadata[statistic_id][0] for statistic_id in result}, + table, + start_time, + {"sum"}, + ): + _metadata = dict(metadata.values()) + for row in tmp: + metadata_by_id = _metadata[row.metadata_id] + statistic_id = metadata_by_id["statistic_id"] + + state_unit = unit = metadata_by_id["unit_of_measurement"] + if state := hass.states.get(statistic_id): + state_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + convert = _get_statistic_to_display_unit_converter(unit, state_unit, units) + + if convert is not None: + prev_sums[statistic_id] = convert(row.sum) + else: + prev_sums[statistic_id] = row.sum + + for statistic_id, rows in result.items(): + prev_sum = prev_sums.get(statistic_id) or 0 + for statistics_row in rows: + if "sum" not in statistics_row: + continue + if drop_sum: + _sum = statistics_row.pop("sum") + else: + _sum = statistics_row["sum"] + if _sum is None: + statistics_row["change"] = None + continue + statistics_row["change"] = _sum - prev_sum + prev_sum = _sum + + def _statistics_during_period_with_session( hass: HomeAssistant, session: Session, @@ -1548,7 +1600,7 @@ def _statistics_during_period_with_session( statistic_ids: set[str] | None, period: Literal["5minute", "day", "hour", "week", "month"], units: dict[str, str] | None, - types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], + _types: set[Literal["change", "last_reset", "max", "mean", "min", "state", "sum"]], ) -> dict[str, list[StatisticsRow]]: """Return statistic data points during UTC period start_time - end_time. @@ -1559,7 +1611,6 @@ def _statistics_during_period_with_session( # This is for backwards compatibility to avoid a breaking change # for custom integrations that call this method. statistic_ids = set(statistic_ids) # type: ignore[unreachable] - metadata = None # Fetch metadata for the given (or all) statistic_ids metadata = get_instance(hass).statistics_meta_manager.get_many( session, statistic_ids=statistic_ids @@ -1567,6 +1618,13 @@ def _statistics_during_period_with_session( if not metadata: return {} + types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]] = set() + for stat_type in _types: + if stat_type == "change": + types.add("sum") + continue + types.add(stat_type) + metadata_ids = None if statistic_ids is not None: metadata_ids = _extract_metadata_and_discard_impossible_columns(metadata, types) @@ -1597,17 +1655,22 @@ def _statistics_during_period_with_session( types, ) - # Return statistics combined with metadata - if period not in ("day", "week", "month"): - return result - if period == "day": - return _reduce_statistics_per_day(result, types) + result = _reduce_statistics_per_day(result, types) if period == "week": - return _reduce_statistics_per_week(result, types) + result = _reduce_statistics_per_week(result, types) - return _reduce_statistics_per_month(result, types) + if period == "month": + result = _reduce_statistics_per_month(result, types) + + if "change" in _types: + _augment_result_with_change( + hass, session, start_time, units, _types, table, metadata, result + ) + + # Return statistics combined with metadata + return result def statistics_during_period( @@ -1617,7 +1680,7 @@ def statistics_during_period( statistic_ids: set[str] | None, period: Literal["5minute", "day", "hour", "week", "month"], units: dict[str, str] | None, - types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], + types: set[Literal["change", "last_reset", "max", "mean", "min", "state", "sum"]], ) -> dict[str, list[StatisticsRow]]: """Return statistic data points during UTC period start_time - end_time. @@ -1886,8 +1949,6 @@ def _sorted_statistics_to_dict( # noqa: C901 assert stats, "stats must not be empty" # Guard against implementation error result: dict[str, list[StatisticsRow]] = defaultdict(list) metadata = dict(_metadata.values()) - need_stat_at_start_time: set[int] = set() - start_time_ts = start_time.timestamp() if start_time else None # Identify metadata IDs for which no data was available at the requested start time field_map: dict[str, int] = {key: idx for idx, key in enumerate(stats[0]._fields)} metadata_id_idx = field_map["metadata_id"] @@ -1898,9 +1959,6 @@ def _sorted_statistics_to_dict( # noqa: C901 for meta_id, group in groupby(stats, key_func): stats_list = stats_by_meta_id[meta_id] = list(group) seen_statistic_ids.add(metadata[meta_id]["statistic_id"]) - first_start_time_ts = stats_list[0][start_ts_idx] - if start_time_ts and first_start_time_ts > start_time_ts: - need_stat_at_start_time.add(meta_id) # Set all statistic IDs to empty lists in result set to maintain the order if statistic_ids is not None: @@ -1911,15 +1969,6 @@ def _sorted_statistics_to_dict( # noqa: C901 if stat_id in seen_statistic_ids: result[stat_id] = [] - # Fetch last known statistics for the needed metadata IDs - if need_stat_at_start_time: - assert start_time # Can not be None if need_stat_at_start_time is not empty - if tmp := _statistics_at_time( - session, need_stat_at_start_time, table, start_time, types - ): - for stat in tmp: - stats_by_meta_id[stat[metadata_id_idx]].insert(0, stat) - # Figure out which fields we need to extract from the SQL result # and which indices they have in the result so we can avoid the overhead # of doing a dict lookup for each row diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py index c52df1b25e31..733dafeba279 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -154,7 +154,7 @@ def _ws_get_statistics_during_period( statistic_ids: set[str] | None, period: Literal["5minute", "day", "hour", "week", "month"], units: dict[str, str], - types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], + types: set[Literal["change", "last_reset", "max", "mean", "min", "state", "sum"]], ) -> str: """Fetch statistics and convert them to json in the executor.""" result = statistics_during_period( @@ -200,7 +200,7 @@ async def ws_handle_get_statistics_during_period( end_time = None if (types := msg.get("types")) is None: - types = {"last_reset", "max", "mean", "min", "state", "sum"} + types = {"change", "last_reset", "max", "mean", "min", "state", "sum"} connection.send_message( await get_instance(hass).async_add_executor_job( _ws_get_statistics_during_period, @@ -225,7 +225,7 @@ async def ws_handle_get_statistics_during_period( vol.Required("period"): vol.Any("5minute", "hour", "day", "week", "month"), vol.Optional("units"): UNIT_SCHEMA, vol.Optional("types"): vol.All( - [vol.Any("last_reset", "max", "mean", "min", "state", "sum")], + [vol.Any("change", "last_reset", "max", "mean", "min", "state", "sum")], vol.Coerce(set), ), } diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index 329ceba6e777..d90b9a64eefd 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -959,6 +959,151 @@ def test_import_statistics_errors( assert get_metadata(hass, statistic_ids={"sensor.total_energy_import"}) == {} +@pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) +@pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") +def test_daily_statistics( + hass_recorder: Callable[..., HomeAssistant], + caplog: pytest.LogCaptureFixture, + timezone, +) -> None: + """Test daily statistics.""" + dt_util.set_default_time_zone(dt_util.get_time_zone(timezone)) + + hass = hass_recorder() + wait_recording_done(hass) + assert "Compiling statistics for" not in caplog.text + assert "Statistics already compiled" not in caplog.text + + zero = dt_util.utcnow() + period1 = dt_util.as_utc(dt_util.parse_datetime("2022-10-03 00:00:00")) + period2 = dt_util.as_utc(dt_util.parse_datetime("2022-10-03 23:00:00")) + period3 = dt_util.as_utc(dt_util.parse_datetime("2022-10-04 00:00:00")) + period4 = dt_util.as_utc(dt_util.parse_datetime("2022-10-04 23:00:00")) + + external_statistics = ( + { + "start": period1, + "last_reset": None, + "state": 0, + "sum": 2, + }, + { + "start": period2, + "last_reset": None, + "state": 1, + "sum": 3, + }, + { + "start": period3, + "last_reset": None, + "state": 2, + "sum": 4, + }, + { + "start": period4, + "last_reset": None, + "state": 3, + "sum": 5, + }, + ) + external_metadata = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": "test", + "statistic_id": "test:total_energy_import", + "unit_of_measurement": "kWh", + } + + async_add_external_statistics(hass, external_metadata, external_statistics) + wait_recording_done(hass) + stats = statistics_during_period( + hass, zero, period="day", statistic_ids={"test:total_energy_import"} + ) + day1_start = dt_util.as_utc(dt_util.parse_datetime("2022-10-03 00:00:00")) + day1_end = dt_util.as_utc(dt_util.parse_datetime("2022-10-04 00:00:00")) + day2_start = dt_util.as_utc(dt_util.parse_datetime("2022-10-04 00:00:00")) + day2_end = dt_util.as_utc(dt_util.parse_datetime("2022-10-05 00:00:00")) + expected_stats = { + "test:total_energy_import": [ + { + "start": day1_start.timestamp(), + "end": day1_end.timestamp(), + "last_reset": None, + "state": 1.0, + "sum": 3.0, + }, + { + "start": day2_start.timestamp(), + "end": day2_end.timestamp(), + "last_reset": None, + "state": 3.0, + "sum": 5.0, + }, + ] + } + assert stats == expected_stats + + # Get change + stats = statistics_during_period( + hass, + start_time=period1, + statistic_ids={"test:total_energy_import"}, + period="day", + types={"change"}, + ) + assert stats == { + "test:total_energy_import": [ + { + "start": day1_start.timestamp(), + "end": day1_end.timestamp(), + "change": 3.0, + }, + { + "start": day2_start.timestamp(), + "end": day2_end.timestamp(), + "change": 2.0, + }, + ] + } + + # Get data with start during the first period + stats = statistics_during_period( + hass, + start_time=period1 + timedelta(hours=1), + statistic_ids={"test:total_energy_import"}, + period="day", + ) + assert stats == expected_stats + + # Try to get data for entities which do not exist + stats = statistics_during_period( + hass, + start_time=zero, + statistic_ids={"not", "the", "same", "test:total_energy_import"}, + period="day", + ) + assert stats == expected_stats + + # Use 5minute to ensure table switch works + stats = statistics_during_period( + hass, + start_time=zero, + statistic_ids=["test:total_energy_import", "with_other"], + period="5minute", + ) + assert stats == {} + + # Ensure future date has not data + future = dt_util.as_utc(dt_util.parse_datetime("2221-11-01 00:00:00")) + stats = statistics_during_period( + hass, start_time=future, end_time=future, period="day" + ) + assert stats == {} + + dt_util.set_default_time_zone(dt_util.get_time_zone("UTC")) + + @pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) @pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") def test_weekly_statistics( @@ -1024,7 +1169,7 @@ def test_weekly_statistics( week1_end = dt_util.as_utc(dt_util.parse_datetime("2022-10-10 00:00:00")) week2_start = dt_util.as_utc(dt_util.parse_datetime("2022-10-10 00:00:00")) week2_end = dt_util.as_utc(dt_util.parse_datetime("2022-10-17 00:00:00")) - assert stats == { + expected_stats = { "test:total_energy_import": [ { "start": week1_start.timestamp(), @@ -1042,32 +1187,49 @@ def test_weekly_statistics( }, ] } + assert stats == expected_stats + # Get change stats = statistics_during_period( hass, - start_time=zero, - statistic_ids=["not", "the", "same", "test:total_energy_import"], + start_time=period1, + statistic_ids={"test:total_energy_import"}, period="week", + types={"change"}, ) assert stats == { "test:total_energy_import": [ { "start": week1_start.timestamp(), "end": week1_end.timestamp(), - "last_reset": None, - "state": 1.0, - "sum": 3.0, + "change": 3.0, }, { "start": week2_start.timestamp(), "end": week2_end.timestamp(), - "last_reset": None, - "state": 3.0, - "sum": 5.0, + "change": 2.0, }, ] } + # Get data with start during the first period + stats = statistics_during_period( + hass, + start_time=period1 + timedelta(days=1), + statistic_ids={"test:total_energy_import"}, + period="week", + ) + assert stats == expected_stats + + # Try to get data for entities which do not exist + stats = statistics_during_period( + hass, + start_time=zero, + statistic_ids={"not", "the", "same", "test:total_energy_import"}, + period="week", + ) + assert stats == expected_stats + # Use 5minute to ensure table switch works stats = statistics_during_period( hass, @@ -1080,7 +1242,7 @@ def test_weekly_statistics( # Ensure future date has not data future = dt_util.as_utc(dt_util.parse_datetime("2221-11-01 00:00:00")) stats = statistics_during_period( - hass, start_time=future, end_time=future, period="month" + hass, start_time=future, end_time=future, period="week" ) assert stats == {} @@ -1152,7 +1314,7 @@ def test_monthly_statistics( sep_end = dt_util.as_utc(dt_util.parse_datetime("2021-10-01 00:00:00")) oct_start = dt_util.as_utc(dt_util.parse_datetime("2021-10-01 00:00:00")) oct_end = dt_util.as_utc(dt_util.parse_datetime("2021-11-01 00:00:00")) - assert stats == { + expected_stats = { "test:total_energy_import": [ { "start": sep_start.timestamp(), @@ -1170,47 +1332,56 @@ def test_monthly_statistics( }, ] } + assert stats == expected_stats + # Get change stats = statistics_during_period( hass, - start_time=zero, - statistic_ids=["not", "the", "same", "test:total_energy_import"], + start_time=period1, + statistic_ids={"test:total_energy_import"}, + period="month", + types={"change"}, + ) + assert stats == { + "test:total_energy_import": [ + { + "start": sep_start.timestamp(), + "end": sep_end.timestamp(), + "change": 3.0, + }, + { + "start": oct_start.timestamp(), + "end": oct_end.timestamp(), + "change": 2.0, + }, + ] + } + # Get data with start during the first period + stats = statistics_during_period( + hass, + start_time=period1 + timedelta(days=1), + statistic_ids={"test:total_energy_import"}, period="month", ) - sep_start = dt_util.as_utc(dt_util.parse_datetime("2021-09-01 00:00:00")) - sep_end = dt_util.as_utc(dt_util.parse_datetime("2021-10-01 00:00:00")) - oct_start = dt_util.as_utc(dt_util.parse_datetime("2021-10-01 00:00:00")) - oct_end = dt_util.as_utc(dt_util.parse_datetime("2021-11-01 00:00:00")) - assert stats == { - "test:total_energy_import": [ - { - "start": sep_start.timestamp(), - "end": sep_end.timestamp(), - "last_reset": None, - "state": pytest.approx(1.0), - "sum": pytest.approx(3.0), - }, - { - "start": oct_start.timestamp(), - "end": oct_end.timestamp(), - "last_reset": None, - "state": pytest.approx(3.0), - "sum": pytest.approx(5.0), - }, - ] - } + assert stats == expected_stats + # Try to get data for entities which do not exist stats = statistics_during_period( hass, start_time=zero, - statistic_ids=["not", "the", "same", "test:total_energy_import"], + statistic_ids={"not", "the", "same", "test:total_energy_import"}, + period="month", + ) + assert stats == expected_stats + + # Get only sum + stats = statistics_during_period( + hass, + start_time=zero, + statistic_ids={"not", "the", "same", "test:total_energy_import"}, period="month", types={"sum"}, ) - sep_start = dt_util.as_utc(dt_util.parse_datetime("2021-09-01 00:00:00")) - sep_end = dt_util.as_utc(dt_util.parse_datetime("2021-10-01 00:00:00")) - oct_start = dt_util.as_utc(dt_util.parse_datetime("2021-10-01 00:00:00")) - oct_end = dt_util.as_utc(dt_util.parse_datetime("2021-11-01 00:00:00")) assert stats == { "test:total_energy_import": [ { @@ -1226,18 +1397,15 @@ def test_monthly_statistics( ] } + # Get only sum + convert units stats = statistics_during_period( hass, start_time=zero, - statistic_ids=["not", "the", "same", "test:total_energy_import"], + statistic_ids={"not", "the", "same", "test:total_energy_import"}, period="month", types={"sum"}, units={"energy": "Wh"}, ) - sep_start = dt_util.as_utc(dt_util.parse_datetime("2021-09-01 00:00:00")) - sep_end = dt_util.as_utc(dt_util.parse_datetime("2021-10-01 00:00:00")) - oct_start = dt_util.as_utc(dt_util.parse_datetime("2021-10-01 00:00:00")) - oct_end = dt_util.as_utc(dt_util.parse_datetime("2021-11-01 00:00:00")) assert stats == { "test:total_energy_import": [ { @@ -1354,3 +1522,571 @@ def test_cache_key_for_generate_statistics_at_time_stmt() -> None: ) cache_key_3 = stmt3._generate_cache_key() assert cache_key_1 != cache_key_3 + + +@pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) +@pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") +def test_change( + hass_recorder: Callable[..., HomeAssistant], + caplog: pytest.LogCaptureFixture, + timezone, +) -> None: + """Test deriving change from sum statistic.""" + dt_util.set_default_time_zone(dt_util.get_time_zone(timezone)) + + hass = hass_recorder() + wait_recording_done(hass) + assert "Compiling statistics for" not in caplog.text + assert "Statistics already compiled" not in caplog.text + + zero = dt_util.utcnow() + period1 = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 00:00:00")) + period2 = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 01:00:00")) + period3 = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 02:00:00")) + period4 = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 03:00:00")) + + external_statistics = ( + { + "start": period1, + "last_reset": None, + "state": 0, + "sum": 2, + }, + { + "start": period2, + "last_reset": None, + "state": 1, + "sum": 3, + }, + { + "start": period3, + "last_reset": None, + "state": 2, + "sum": 5, + }, + { + "start": period4, + "last_reset": None, + "state": 3, + "sum": 8, + }, + ) + external_metadata = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": "recorder", + "statistic_id": "sensor.total_energy_import", + "unit_of_measurement": "kWh", + } + + async_import_statistics(hass, external_metadata, external_statistics) + wait_recording_done(hass) + # Get change from far in the past + stats = statistics_during_period( + hass, + zero, + period="hour", + statistic_ids={"sensor.total_energy_import"}, + types={"change"}, + ) + hour1_start = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 00:00:00")) + hour1_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 01:00:00")) + hour2_start = hour1_end + hour2_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 02:00:00")) + hour3_start = hour2_end + hour3_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 03:00:00")) + hour4_start = hour3_end + hour4_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 04:00:00")) + expected_stats = { + "sensor.total_energy_import": [ + { + "start": hour1_start.timestamp(), + "end": hour1_end.timestamp(), + "change": 2.0, + }, + { + "start": hour2_start.timestamp(), + "end": hour2_end.timestamp(), + "change": 1.0, + }, + { + "start": hour3_start.timestamp(), + "end": hour3_end.timestamp(), + "change": 2.0, + }, + { + "start": hour4_start.timestamp(), + "end": hour4_end.timestamp(), + "change": 3.0, + }, + ] + } + assert stats == expected_stats + + # Get change + sum from far in the past + stats = statistics_during_period( + hass, + zero, + period="hour", + statistic_ids={"sensor.total_energy_import"}, + types={"change", "sum"}, + ) + hour1_start = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 00:00:00")) + hour1_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 01:00:00")) + hour2_start = hour1_end + hour2_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 02:00:00")) + hour3_start = hour2_end + hour3_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 03:00:00")) + hour4_start = hour3_end + hour4_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 04:00:00")) + expected_stats_change_sum = { + "sensor.total_energy_import": [ + { + "start": hour1_start.timestamp(), + "end": hour1_end.timestamp(), + "change": 2.0, + "sum": 2.0, + }, + { + "start": hour2_start.timestamp(), + "end": hour2_end.timestamp(), + "change": 1.0, + "sum": 3.0, + }, + { + "start": hour3_start.timestamp(), + "end": hour3_end.timestamp(), + "change": 2.0, + "sum": 5.0, + }, + { + "start": hour4_start.timestamp(), + "end": hour4_end.timestamp(), + "change": 3.0, + "sum": 8.0, + }, + ] + } + assert stats == expected_stats_change_sum + + # Get change from far in the past with unit conversion + stats = statistics_during_period( + hass, + start_time=hour1_start, + statistic_ids={"sensor.total_energy_import"}, + period="hour", + types={"change"}, + units={"energy": "Wh"}, + ) + expected_stats_wh = { + "sensor.total_energy_import": [ + { + "start": hour1_start.timestamp(), + "end": hour1_end.timestamp(), + "change": 2.0 * 1000, + }, + { + "start": hour2_start.timestamp(), + "end": hour2_end.timestamp(), + "change": 1.0 * 1000, + }, + { + "start": hour3_start.timestamp(), + "end": hour3_end.timestamp(), + "change": 2.0 * 1000, + }, + { + "start": hour4_start.timestamp(), + "end": hour4_end.timestamp(), + "change": 3.0 * 1000, + }, + ] + } + assert stats == expected_stats_wh + + # Get change from far in the past with implicit unit conversion + hass.states.async_set( + "sensor.total_energy_import", "unknown", {"unit_of_measurement": "MWh"} + ) + stats = statistics_during_period( + hass, + start_time=hour1_start, + statistic_ids={"sensor.total_energy_import"}, + period="hour", + types={"change"}, + ) + expected_stats_mwh = { + "sensor.total_energy_import": [ + { + "start": hour1_start.timestamp(), + "end": hour1_end.timestamp(), + "change": 2.0 / 1000, + }, + { + "start": hour2_start.timestamp(), + "end": hour2_end.timestamp(), + "change": 1.0 / 1000, + }, + { + "start": hour3_start.timestamp(), + "end": hour3_end.timestamp(), + "change": 2.0 / 1000, + }, + { + "start": hour4_start.timestamp(), + "end": hour4_end.timestamp(), + "change": 3.0 / 1000, + }, + ] + } + assert stats == expected_stats_mwh + hass.states.async_remove("sensor.total_energy_import") + + # Get change from the first recorded hour + stats = statistics_during_period( + hass, + start_time=hour1_start, + statistic_ids={"sensor.total_energy_import"}, + period="hour", + types={"change"}, + ) + assert stats == expected_stats + + # Get change from the first recorded hour with unit conversion + stats = statistics_during_period( + hass, + start_time=hour1_start, + statistic_ids={"sensor.total_energy_import"}, + period="hour", + types={"change"}, + units={"energy": "Wh"}, + ) + assert stats == expected_stats_wh + + # Get change from the first recorded hour with implicit unit conversion + hass.states.async_set( + "sensor.total_energy_import", "unknown", {"unit_of_measurement": "MWh"} + ) + stats = statistics_during_period( + hass, + start_time=hour1_start, + statistic_ids={"sensor.total_energy_import"}, + period="hour", + types={"change"}, + ) + assert stats == expected_stats_mwh + hass.states.async_remove("sensor.total_energy_import") + + # Get change from the second recorded hour + stats = statistics_during_period( + hass, + start_time=hour2_start, + statistic_ids={"sensor.total_energy_import"}, + period="hour", + types={"change"}, + ) + assert stats == { + "sensor.total_energy_import": expected_stats["sensor.total_energy_import"][1:4] + } + + # Get change from the second recorded hour with unit conversion + stats = statistics_during_period( + hass, + start_time=hour2_start, + statistic_ids={"sensor.total_energy_import"}, + period="hour", + types={"change"}, + units={"energy": "Wh"}, + ) + assert stats == { + "sensor.total_energy_import": expected_stats_wh["sensor.total_energy_import"][ + 1:4 + ] + } + + # Get change from the second recorded hour with implicit unit conversion + hass.states.async_set( + "sensor.total_energy_import", "unknown", {"unit_of_measurement": "MWh"} + ) + stats = statistics_during_period( + hass, + start_time=hour2_start, + statistic_ids={"sensor.total_energy_import"}, + period="hour", + types={"change"}, + ) + assert stats == { + "sensor.total_energy_import": expected_stats_mwh["sensor.total_energy_import"][ + 1:4 + ] + } + hass.states.async_remove("sensor.total_energy_import") + + # Get change from the second until the third recorded hour + stats = statistics_during_period( + hass, + start_time=hour2_start, + end_time=hour4_start, + statistic_ids={"sensor.total_energy_import"}, + period="hour", + types={"change"}, + ) + assert stats == { + "sensor.total_energy_import": expected_stats["sensor.total_energy_import"][1:3] + } + + # Get change from the fourth recorded hour + stats = statistics_during_period( + hass, + start_time=hour4_start, + statistic_ids={"sensor.total_energy_import"}, + period="hour", + types={"change"}, + ) + assert stats == { + "sensor.total_energy_import": expected_stats["sensor.total_energy_import"][3:4] + } + + # Test change with a far future start date + future = dt_util.as_utc(dt_util.parse_datetime("2221-11-01 00:00:00")) + stats = statistics_during_period( + hass, + start_time=future, + statistic_ids={"sensor.total_energy_import"}, + period="hour", + types={"change"}, + ) + assert stats == {} + + dt_util.set_default_time_zone(dt_util.get_time_zone("UTC")) + + +@pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) +@pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") +def test_change_with_none( + hass_recorder: Callable[..., HomeAssistant], + caplog: pytest.LogCaptureFixture, + timezone, +) -> None: + """Test deriving change from sum statistic. + + This tests the behavior when some record has None sum. The calculated change + is not expected to be correct, but we should not raise on this error. + """ + dt_util.set_default_time_zone(dt_util.get_time_zone(timezone)) + + hass = hass_recorder() + wait_recording_done(hass) + assert "Compiling statistics for" not in caplog.text + assert "Statistics already compiled" not in caplog.text + + zero = dt_util.utcnow() + period1 = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 00:00:00")) + period2 = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 01:00:00")) + period3 = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 02:00:00")) + period4 = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 03:00:00")) + + external_statistics = ( + { + "start": period1, + "last_reset": None, + "state": 0, + "sum": 2, + }, + { + "start": period2, + "last_reset": None, + "state": 1, + "sum": 3, + }, + { + "start": period3, + "last_reset": None, + "state": 2, + "sum": None, + }, + { + "start": period4, + "last_reset": None, + "state": 3, + "sum": 8, + }, + ) + external_metadata = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": "test", + "statistic_id": "test:total_energy_import", + "unit_of_measurement": "kWh", + } + + async_add_external_statistics(hass, external_metadata, external_statistics) + wait_recording_done(hass) + # Get change from far in the past + stats = statistics_during_period( + hass, + zero, + period="hour", + statistic_ids={"test:total_energy_import"}, + types={"change"}, + ) + hour1_start = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 00:00:00")) + hour1_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 01:00:00")) + hour2_start = hour1_end + hour2_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 02:00:00")) + hour3_start = hour2_end + hour3_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 03:00:00")) + hour4_start = hour3_end + hour4_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 04:00:00")) + expected_stats = { + "test:total_energy_import": [ + { + "start": hour1_start.timestamp(), + "end": hour1_end.timestamp(), + "change": 2.0, + }, + { + "start": hour2_start.timestamp(), + "end": hour2_end.timestamp(), + "change": 1.0, + }, + { + "start": hour3_start.timestamp(), + "end": hour3_end.timestamp(), + "change": None, + }, + { + "start": hour4_start.timestamp(), + "end": hour4_end.timestamp(), + "change": 5.0, + }, + ] + } + assert stats == expected_stats + + # Get change from far in the past with unit conversion + stats = statistics_during_period( + hass, + start_time=hour1_start, + statistic_ids={"test:total_energy_import"}, + period="hour", + types={"change"}, + units={"energy": "Wh"}, + ) + expected_stats_wh = { + "test:total_energy_import": [ + { + "start": hour1_start.timestamp(), + "end": hour1_end.timestamp(), + "change": 2.0 * 1000, + }, + { + "start": hour2_start.timestamp(), + "end": hour2_end.timestamp(), + "change": 1.0 * 1000, + }, + { + "start": hour3_start.timestamp(), + "end": hour3_end.timestamp(), + "change": None, + }, + { + "start": hour4_start.timestamp(), + "end": hour4_end.timestamp(), + "change": 5.0 * 1000, + }, + ] + } + assert stats == expected_stats_wh + + # Get change from the first recorded hour + stats = statistics_during_period( + hass, + start_time=hour1_start, + statistic_ids={"test:total_energy_import"}, + period="hour", + types={"change"}, + ) + assert stats == expected_stats + + # Get change from the first recorded hour with unit conversion + stats = statistics_during_period( + hass, + start_time=hour1_start, + statistic_ids={"test:total_energy_import"}, + period="hour", + types={"change"}, + units={"energy": "Wh"}, + ) + assert stats == expected_stats_wh + + # Get change from the second recorded hour + stats = statistics_during_period( + hass, + start_time=hour2_start, + statistic_ids={"test:total_energy_import"}, + period="hour", + types={"change"}, + ) + assert stats == { + "test:total_energy_import": expected_stats["test:total_energy_import"][1:4] + } + + # Get change from the second recorded hour with unit conversion + stats = statistics_during_period( + hass, + start_time=hour2_start, + statistic_ids={"test:total_energy_import"}, + period="hour", + types={"change"}, + units={"energy": "Wh"}, + ) + assert stats == { + "test:total_energy_import": expected_stats_wh["test:total_energy_import"][1:4] + } + + # Get change from the second until the third recorded hour + stats = statistics_during_period( + hass, + start_time=hour2_start, + end_time=hour4_start, + statistic_ids={"test:total_energy_import"}, + period="hour", + types={"change"}, + ) + assert stats == { + "test:total_energy_import": expected_stats["test:total_energy_import"][1:3] + } + + # Get change from the fourth recorded hour + stats = statistics_during_period( + hass, + start_time=hour4_start, + statistic_ids={"test:total_energy_import"}, + period="hour", + types={"change"}, + ) + assert stats == { + "test:total_energy_import": [ + { + "start": hour4_start.timestamp(), + "end": hour4_end.timestamp(), + "change": 8.0, # Assumed to be 8 because the previous hour has no data + }, + ] + } + + # Test change with a far future start date + future = dt_util.as_utc(dt_util.parse_datetime("2221-11-01 00:00:00")) + stats = statistics_during_period( + hass, + start_time=future, + statistic_ids={"test:total_energy_import"}, + period="hour", + types={"change"}, + ) + assert stats == {} + + dt_util.set_default_time_zone(dt_util.get_time_zone("UTC")) diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index fffeb0198224..37f5dc77d006 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -1037,6 +1037,7 @@ async def test_sum_statistics_during_period_unit_conversion( { "start": int(now.timestamp() * 1000), "end": int((now + timedelta(minutes=5)).timestamp() * 1000), + "change": pytest.approx(value), "last_reset": None, "state": pytest.approx(value), "sum": pytest.approx(value), @@ -1062,6 +1063,7 @@ async def test_sum_statistics_during_period_unit_conversion( { "start": int(now.timestamp() * 1000), "end": int((now + timedelta(minutes=5)).timestamp() * 1000), + "change": pytest.approx(converted_value), "last_reset": None, "state": pytest.approx(converted_value), "sum": pytest.approx(converted_value), diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 9b297bf884f1..53fb2493d706 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -915,11 +915,11 @@ async def test_compile_hourly_sum_statistics_amount( } assert stats == expected_stats - # With an offset of 1 minute, we expect to get all periods + # With an offset of 1 minute, we expect to get the 2nd and 3rd periods stats = statistics_during_period( hass, period0 + timedelta(minutes=1), period="5minute" ) - assert stats == expected_stats + assert stats == {"sensor.test1": expected_stats["sensor.test1"][1:3]} # With an offset of 5 minutes, we expect to get the 2nd and 3rd periods stats = statistics_during_period( @@ -927,11 +927,11 @@ async def test_compile_hourly_sum_statistics_amount( ) assert stats == {"sensor.test1": expected_stats["sensor.test1"][1:3]} - # With an offset of 6 minutes, we expect to get the 2nd and 3rd periods + # With an offset of 6 minutes, we expect to get the 3rd period stats = statistics_during_period( hass, period0 + timedelta(minutes=6), period="5minute" ) - assert stats == {"sensor.test1": expected_stats["sensor.test1"][1:3]} + assert stats == {"sensor.test1": expected_stats["sensor.test1"][2:3]} assert "Error while processing event StatisticsTask" not in caplog.text assert "Detected new cycle for sensor.test1, last_reset set to" in caplog.text From 75f8ea48f42f4a1bee161b6991d521566512324f Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 11 May 2023 10:51:00 +0200 Subject: [PATCH 312/822] Add tts get engine ws command (#92336) Co-authored-by: Erik Montnemery --- homeassistant/components/tts/__init__.py | 42 +++++++++++++++++++++ tests/components/tts/test_init.py | 48 +++++++++++++++++++++++- 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index a90a69edcdba..7a55fb9b77d0 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -206,6 +206,7 @@ def async_get_text_to_speech_languages(hass: HomeAssistant) -> set[str]: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up TTS.""" websocket_api.async_register_command(hass, websocket_list_engines) + websocket_api.async_register_command(hass, websocket_get_engine) websocket_api.async_register_command(hass, websocket_list_engine_voices) # Legacy config options @@ -969,6 +970,47 @@ def websocket_list_engines( ) +@websocket_api.websocket_command( + { + "type": "tts/engine/get", + vol.Required("engine_id"): str, + } +) +@callback +def websocket_get_engine( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict +) -> None: + """Get text to speech engine info.""" + component: EntityComponent[TextToSpeechEntity] = hass.data[DOMAIN] + manager: SpeechManager = hass.data[DATA_TTS_MANAGER] + + engine_id = msg["engine_id"] + provider_info: dict[str, Any] + + provider: TextToSpeechEntity | Provider | None = next( + (entity for entity in component.entities if entity.entity_id == engine_id), None + ) + if not provider: + provider = manager.providers.get(engine_id) + + if not provider: + connection.send_error( + msg["id"], + websocket_api.const.ERR_NOT_FOUND, + f"tts engine {engine_id} not found", + ) + return + + provider_info = { + "engine_id": engine_id, + "supported_languages": provider.supported_languages, + } + + connection.send_message( + websocket_api.result_message(msg["id"], {"provider": provider_info}) + ) + + @websocket_api.websocket_command( { "type": "tts/engine/voices", diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index cdb8fd9a4139..b43687ba931a 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -1623,7 +1623,7 @@ async def test_fetching_in_async( async def test_ws_list_engines( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup: str, engine_id: str ) -> None: - """Test streaming audio and getting response.""" + """Test listing tts engines and supported languages.""" client = await hass_ws_client() await client.send_json_auto_id({"type": "tts/engine/list"}) @@ -1690,6 +1690,50 @@ async def test_ws_list_engines( } +@pytest.mark.parametrize( + ("setup", "engine_id"), + [ + ("mock_setup", "test"), + ("mock_config_entry_setup", "tts.test"), + ], + indirect=["setup"], +) +async def test_ws_get_engine( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup: str, engine_id: str +) -> None: + """Test getting an tts engine.""" + client = await hass_ws_client() + + await client.send_json_auto_id({"type": "tts/engine/get", "engine_id": engine_id}) + + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == { + "provider": { + "engine_id": engine_id, + "supported_languages": ["de_CH", "de_DE", "en_GB", "en_US"], + } + } + + +@pytest.mark.parametrize( + ("setup", "engine_id"), + [("mock_setup", "not_existing"), ("mock_config_entry_setup", "tts.not_existing")], + indirect=["setup"], +) +async def test_ws_get_engine_none_existing( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup: str, engine_id: str +) -> None: + """Test getting a non existing tts engine.""" + client = await hass_ws_client() + + await client.send_json_auto_id({"type": "tts/engine/get", "engine_id": engine_id}) + + msg = await client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == "not_found" + + @pytest.mark.parametrize( ("setup", "engine_id"), [ @@ -1701,7 +1745,7 @@ async def test_ws_list_engines( async def test_ws_list_voices( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup: str, engine_id: str ) -> None: - """Test streaming audio and getting response.""" + """Test listing supported voices for a tts engine and language.""" client = await hass_ws_client() await client.send_json_auto_id( From 67d5ca48947810a076b9798fc79588be7f9bd167 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 11 May 2023 11:27:19 +0200 Subject: [PATCH 313/822] Mark entities from the sun as diagnostic (#92909) --- homeassistant/components/sun/sensor.py | 3 ++- tests/components/sun/test_sensor.py | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sun/sensor.py b/homeassistant/components/sun/sensor.py index 8a390ea2a27c..344e0c2179ef 100644 --- a/homeassistant/components/sun/sensor.py +++ b/homeassistant/components/sun/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import DEGREE +from homeassistant.const import DEGREE, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo @@ -118,6 +118,7 @@ class SunSensor(SensorEntity): """Representation of a Sun Sensor.""" _attr_has_entity_name = True + _attr_entity_category = EntityCategory.DIAGNOSTIC entity_description: SunSensorEntityDescription def __init__( diff --git a/tests/components/sun/test_sensor.py b/tests/components/sun/test_sensor.py index 13f4fd0d62b1..384535692698 100644 --- a/tests/components/sun/test_sensor.py +++ b/tests/components/sun/test_sensor.py @@ -6,6 +6,7 @@ import astral.sun from freezegun import freeze_time from homeassistant.components import sun +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant import homeassistant.helpers.entity_registry as er from homeassistant.setup import async_setup_component @@ -98,4 +99,6 @@ async def test_setting_rising(hass: HomeAssistant) -> None: entity_reg = er.async_get(hass) entity = entity_reg.async_get("sensor.sun_next_dawn") + assert entity + assert entity.entity_category is EntityCategory.DIAGNOSTIC assert entity.unique_id == f"{entry_ids[0].entry_id}-next_dawn" From c1f716487c3834988d3be39b7ff572d6a0d38bd2 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 11 May 2023 11:28:33 +0200 Subject: [PATCH 314/822] Include data for entire periods in response to recorder/statistics_during_period (#92848) * Include data for entire periods in response to recorder/statistics_during_period * Break out function for calculating the end of the month --- .../components/recorder/statistics.py | 43 ++- tests/components/recorder/test_statistics.py | 342 ++++++++++++++++-- 2 files changed, 341 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 2ef790024fdb..70f35d0349a5 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -981,6 +981,14 @@ def _reduce_statistics_per_week( ) +def _find_month_end_time(timestamp: datetime) -> datetime: + """Return the end of the month (midnight at the first day of the next month).""" + # We add 4 days to the end to make sure we are in the next month + return (timestamp.replace(day=28) + timedelta(days=4)).replace( + day=1, hour=0, minute=0, second=0, microsecond=0 + ) + + def reduce_month_ts_factory() -> ( tuple[ Callable[[float, float], bool], @@ -1007,10 +1015,7 @@ def reduce_month_ts_factory() -> ( start_local = _local_from_timestamp(time).replace( day=1, hour=0, minute=0, second=0, microsecond=0 ) - # We add 4 days to the end to make sure we are in the next month - end_local = (start_local.replace(day=28) + timedelta(days=4)).replace( - day=1, hour=0, minute=0, second=0, microsecond=0 - ) + end_local = _find_month_end_time(start_local) return ( start_local.astimezone(dt_util.UTC).timestamp(), end_local.astimezone(dt_util.UTC).timestamp(), @@ -1629,6 +1634,36 @@ def _statistics_during_period_with_session( if statistic_ids is not None: metadata_ids = _extract_metadata_and_discard_impossible_columns(metadata, types) + # Align start_time and end_time with the period + if period == "day": + start_time = dt_util.as_local(start_time).replace( + hour=0, minute=0, second=0, microsecond=0 + ) + start_time = start_time.replace() + if end_time is not None: + end_local = dt_util.as_local(end_time) + end_time = end_local.replace( + hour=0, minute=0, second=0, microsecond=0 + ) + timedelta(days=1) + elif period == "week": + start_local = dt_util.as_local(start_time) + start_time = start_local.replace( + hour=0, minute=0, second=0, microsecond=0 + ) - timedelta(days=start_local.weekday()) + if end_time is not None: + end_local = dt_util.as_local(end_time) + end_time = ( + end_local.replace(hour=0, minute=0, second=0, microsecond=0) + - timedelta(days=end_local.weekday()) + + timedelta(days=7) + ) + elif period == "month": + start_time = dt_util.as_local(start_time).replace( + day=1, hour=0, minute=0, second=0, microsecond=0 + ) + if end_time is not None: + end_time = _find_month_end_time(dt_util.as_local(end_time)) + table: type[Statistics | StatisticsShortTerm] = ( Statistics if period != "5minute" else StatisticsShortTerm ) diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index d90b9a64eefd..de10d9f569b9 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -961,7 +961,7 @@ def test_import_statistics_errors( @pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) @pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") -def test_daily_statistics( +def test_daily_statistics_sum( hass_recorder: Callable[..., HomeAssistant], caplog: pytest.LogCaptureFixture, timezone, @@ -979,6 +979,8 @@ def test_daily_statistics( period2 = dt_util.as_utc(dt_util.parse_datetime("2022-10-03 23:00:00")) period3 = dt_util.as_utc(dt_util.parse_datetime("2022-10-04 00:00:00")) period4 = dt_util.as_utc(dt_util.parse_datetime("2022-10-04 23:00:00")) + period5 = dt_util.as_utc(dt_util.parse_datetime("2022-10-05 00:00:00")) + period6 = dt_util.as_utc(dt_util.parse_datetime("2022-10-05 23:00:00")) external_statistics = ( { @@ -1005,6 +1007,18 @@ def test_daily_statistics( "state": 3, "sum": 5, }, + { + "start": period5, + "last_reset": None, + "state": 4, + "sum": 6, + }, + { + "start": period6, + "last_reset": None, + "state": 5, + "sum": 7, + }, ) external_metadata = { "has_mean": False, @@ -1024,6 +1038,8 @@ def test_daily_statistics( day1_end = dt_util.as_utc(dt_util.parse_datetime("2022-10-04 00:00:00")) day2_start = dt_util.as_utc(dt_util.parse_datetime("2022-10-04 00:00:00")) day2_end = dt_util.as_utc(dt_util.parse_datetime("2022-10-05 00:00:00")) + day3_start = dt_util.as_utc(dt_util.parse_datetime("2022-10-05 00:00:00")) + day3_end = dt_util.as_utc(dt_util.parse_datetime("2022-10-06 00:00:00")) expected_stats = { "test:total_energy_import": [ { @@ -1040,6 +1056,13 @@ def test_daily_statistics( "state": 3.0, "sum": 5.0, }, + { + "start": day3_start.timestamp(), + "end": day3_end.timestamp(), + "last_reset": None, + "state": 5.0, + "sum": 7.0, + }, ] } assert stats == expected_stats @@ -1064,6 +1087,11 @@ def test_daily_statistics( "end": day2_end.timestamp(), "change": 2.0, }, + { + "start": day3_start.timestamp(), + "end": day3_end.timestamp(), + "change": 2.0, + }, ] } @@ -1076,6 +1104,16 @@ def test_daily_statistics( ) assert stats == expected_stats + # Get data with end during the third period + stats = statistics_during_period( + hass, + start_time=zero, + end_time=period6 - timedelta(hours=1), + statistic_ids={"test:total_energy_import"}, + period="day", + ) + assert stats == expected_stats + # Try to get data for entities which do not exist stats = statistics_during_period( hass, @@ -1106,7 +1144,7 @@ def test_daily_statistics( @pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) @pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") -def test_weekly_statistics( +def test_weekly_statistics_mean( hass_recorder: Callable[..., HomeAssistant], caplog: pytest.LogCaptureFixture, timezone, @@ -1121,7 +1159,7 @@ def test_weekly_statistics( zero = dt_util.utcnow() period1 = dt_util.as_utc(dt_util.parse_datetime("2022-10-03 00:00:00")) - period2 = dt_util.as_utc(dt_util.parse_datetime("2022-10-09 23:00:00")) + period2 = dt_util.as_utc(dt_util.parse_datetime("2022-10-05 23:00:00")) period3 = dt_util.as_utc(dt_util.parse_datetime("2022-10-10 00:00:00")) period4 = dt_util.as_utc(dt_util.parse_datetime("2022-10-16 23:00:00")) @@ -1129,31 +1167,35 @@ def test_weekly_statistics( { "start": period1, "last_reset": None, - "state": 0, - "sum": 2, + "max": 0, + "mean": 10, + "min": -100, }, { "start": period2, "last_reset": None, - "state": 1, - "sum": 3, + "max": 10, + "mean": 20, + "min": -90, }, { "start": period3, "last_reset": None, - "state": 2, - "sum": 4, + "max": 20, + "mean": 30, + "min": -80, }, { "start": period4, "last_reset": None, - "state": 3, - "sum": 5, + "max": 30, + "mean": 40, + "min": -70, }, ) external_metadata = { - "has_mean": False, - "has_sum": True, + "has_mean": True, + "has_sum": False, "name": "Total imported energy", "source": "test", "statistic_id": "test:total_energy_import", @@ -1162,6 +1204,7 @@ def test_weekly_statistics( async_add_external_statistics(hass, external_metadata, external_statistics) wait_recording_done(hass) + # Get all data stats = statistics_during_period( hass, zero, period="week", statistic_ids={"test:total_energy_import"} ) @@ -1175,42 +1218,30 @@ def test_weekly_statistics( "start": week1_start.timestamp(), "end": week1_end.timestamp(), "last_reset": None, - "state": 1.0, - "sum": 3.0, + "max": 10, + "mean": 15, + "min": -100, }, { "start": week2_start.timestamp(), "end": week2_end.timestamp(), "last_reset": None, - "state": 3.0, - "sum": 5.0, + "max": 30, + "mean": 35, + "min": -80, }, ] } assert stats == expected_stats - # Get change + # Get data starting with start of the first period stats = statistics_during_period( hass, start_time=period1, statistic_ids={"test:total_energy_import"}, period="week", - types={"change"}, ) - assert stats == { - "test:total_energy_import": [ - { - "start": week1_start.timestamp(), - "end": week1_end.timestamp(), - "change": 3.0, - }, - { - "start": week2_start.timestamp(), - "end": week2_end.timestamp(), - "change": 2.0, - }, - ] - } + assert stats == expected_stats # Get data with start during the first period stats = statistics_during_period( @@ -1250,13 +1281,13 @@ def test_weekly_statistics( @pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) -@pytest.mark.freeze_time("2021-08-01 00:00:00+00:00") -def test_monthly_statistics( +@pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") +def test_weekly_statistics_sum( hass_recorder: Callable[..., HomeAssistant], caplog: pytest.LogCaptureFixture, timezone, ) -> None: - """Test monthly statistics.""" + """Test weekly statistics.""" dt_util.set_default_time_zone(dt_util.get_time_zone(timezone)) hass = hass_recorder() @@ -1265,10 +1296,12 @@ def test_monthly_statistics( assert "Statistics already compiled" not in caplog.text zero = dt_util.utcnow() - period1 = dt_util.as_utc(dt_util.parse_datetime("2021-09-01 00:00:00")) - period2 = dt_util.as_utc(dt_util.parse_datetime("2021-09-30 23:00:00")) - period3 = dt_util.as_utc(dt_util.parse_datetime("2021-10-01 00:00:00")) - period4 = dt_util.as_utc(dt_util.parse_datetime("2021-10-31 23:00:00")) + period1 = dt_util.as_utc(dt_util.parse_datetime("2022-10-03 00:00:00")) + period2 = dt_util.as_utc(dt_util.parse_datetime("2022-10-09 23:00:00")) + period3 = dt_util.as_utc(dt_util.parse_datetime("2022-10-10 00:00:00")) + period4 = dt_util.as_utc(dt_util.parse_datetime("2022-10-16 23:00:00")) + period5 = dt_util.as_utc(dt_util.parse_datetime("2022-10-17 00:00:00")) + period6 = dt_util.as_utc(dt_util.parse_datetime("2022-10-23 23:00:00")) external_statistics = ( { @@ -1295,6 +1328,201 @@ def test_monthly_statistics( "state": 3, "sum": 5, }, + { + "start": period5, + "last_reset": None, + "state": 4, + "sum": 6, + }, + { + "start": period6, + "last_reset": None, + "state": 5, + "sum": 7, + }, + ) + external_metadata = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": "test", + "statistic_id": "test:total_energy_import", + "unit_of_measurement": "kWh", + } + + async_add_external_statistics(hass, external_metadata, external_statistics) + wait_recording_done(hass) + stats = statistics_during_period( + hass, zero, period="week", statistic_ids={"test:total_energy_import"} + ) + week1_start = dt_util.as_utc(dt_util.parse_datetime("2022-10-03 00:00:00")) + week1_end = dt_util.as_utc(dt_util.parse_datetime("2022-10-10 00:00:00")) + week2_start = dt_util.as_utc(dt_util.parse_datetime("2022-10-10 00:00:00")) + week2_end = dt_util.as_utc(dt_util.parse_datetime("2022-10-17 00:00:00")) + week3_start = dt_util.as_utc(dt_util.parse_datetime("2022-10-17 00:00:00")) + week3_end = dt_util.as_utc(dt_util.parse_datetime("2022-10-24 00:00:00")) + expected_stats = { + "test:total_energy_import": [ + { + "start": week1_start.timestamp(), + "end": week1_end.timestamp(), + "last_reset": None, + "state": 1.0, + "sum": 3.0, + }, + { + "start": week2_start.timestamp(), + "end": week2_end.timestamp(), + "last_reset": None, + "state": 3.0, + "sum": 5.0, + }, + { + "start": week3_start.timestamp(), + "end": week3_end.timestamp(), + "last_reset": None, + "state": 5.0, + "sum": 7.0, + }, + ] + } + assert stats == expected_stats + + # Get change + stats = statistics_during_period( + hass, + start_time=period1, + statistic_ids={"test:total_energy_import"}, + period="week", + types={"change"}, + ) + assert stats == { + "test:total_energy_import": [ + { + "start": week1_start.timestamp(), + "end": week1_end.timestamp(), + "change": 3.0, + }, + { + "start": week2_start.timestamp(), + "end": week2_end.timestamp(), + "change": 2.0, + }, + { + "start": week3_start.timestamp(), + "end": week3_end.timestamp(), + "change": 2.0, + }, + ] + } + + # Get data with start during the first period + stats = statistics_during_period( + hass, + start_time=period1 + timedelta(days=1), + statistic_ids={"test:total_energy_import"}, + period="week", + ) + assert stats == expected_stats + + # Get data with end during the third period + stats = statistics_during_period( + hass, + start_time=zero, + end_time=period6 - timedelta(days=1), + statistic_ids={"test:total_energy_import"}, + period="week", + ) + assert stats == expected_stats + + # Try to get data for entities which do not exist + stats = statistics_during_period( + hass, + start_time=zero, + statistic_ids={"not", "the", "same", "test:total_energy_import"}, + period="week", + ) + assert stats == expected_stats + + # Use 5minute to ensure table switch works + stats = statistics_during_period( + hass, + start_time=zero, + statistic_ids=["test:total_energy_import", "with_other"], + period="5minute", + ) + assert stats == {} + + # Ensure future date has not data + future = dt_util.as_utc(dt_util.parse_datetime("2221-11-01 00:00:00")) + stats = statistics_during_period( + hass, start_time=future, end_time=future, period="week" + ) + assert stats == {} + + dt_util.set_default_time_zone(dt_util.get_time_zone("UTC")) + + +@pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) +@pytest.mark.freeze_time("2021-08-01 00:00:00+00:00") +def test_monthly_statistics_sum( + hass_recorder: Callable[..., HomeAssistant], + caplog: pytest.LogCaptureFixture, + timezone, +) -> None: + """Test monthly statistics.""" + dt_util.set_default_time_zone(dt_util.get_time_zone(timezone)) + + hass = hass_recorder() + wait_recording_done(hass) + assert "Compiling statistics for" not in caplog.text + assert "Statistics already compiled" not in caplog.text + + zero = dt_util.utcnow() + period1 = dt_util.as_utc(dt_util.parse_datetime("2021-09-01 00:00:00")) + period2 = dt_util.as_utc(dt_util.parse_datetime("2021-09-30 23:00:00")) + period3 = dt_util.as_utc(dt_util.parse_datetime("2021-10-01 00:00:00")) + period4 = dt_util.as_utc(dt_util.parse_datetime("2021-10-31 23:00:00")) + period5 = dt_util.as_utc(dt_util.parse_datetime("2021-11-01 00:00:00")) + period6 = dt_util.as_utc(dt_util.parse_datetime("2021-11-30 23:00:00")) + + external_statistics = ( + { + "start": period1, + "last_reset": None, + "state": 0, + "sum": 2, + }, + { + "start": period2, + "last_reset": None, + "state": 1, + "sum": 3, + }, + { + "start": period3, + "last_reset": None, + "state": 2, + "sum": 4, + }, + { + "start": period4, + "last_reset": None, + "state": 3, + "sum": 5, + }, + { + "start": period5, + "last_reset": None, + "state": 4, + "sum": 6, + }, + { + "start": period6, + "last_reset": None, + "state": 5, + "sum": 7, + }, ) external_metadata = { "has_mean": False, @@ -1314,6 +1542,8 @@ def test_monthly_statistics( sep_end = dt_util.as_utc(dt_util.parse_datetime("2021-10-01 00:00:00")) oct_start = dt_util.as_utc(dt_util.parse_datetime("2021-10-01 00:00:00")) oct_end = dt_util.as_utc(dt_util.parse_datetime("2021-11-01 00:00:00")) + nov_start = dt_util.as_utc(dt_util.parse_datetime("2021-11-01 00:00:00")) + nov_end = dt_util.as_utc(dt_util.parse_datetime("2021-12-01 00:00:00")) expected_stats = { "test:total_energy_import": [ { @@ -1330,6 +1560,13 @@ def test_monthly_statistics( "state": pytest.approx(3.0), "sum": pytest.approx(5.0), }, + { + "start": nov_start.timestamp(), + "end": nov_end.timestamp(), + "last_reset": None, + "state": 5.0, + "sum": 7.0, + }, ] } assert stats == expected_stats @@ -1354,6 +1591,11 @@ def test_monthly_statistics( "end": oct_end.timestamp(), "change": 2.0, }, + { + "start": nov_start.timestamp(), + "end": nov_end.timestamp(), + "change": 2.0, + }, ] } # Get data with start during the first period @@ -1365,6 +1607,16 @@ def test_monthly_statistics( ) assert stats == expected_stats + # Get data with end during the third period + stats = statistics_during_period( + hass, + start_time=zero, + end_time=period6 - timedelta(days=1), + statistic_ids={"test:total_energy_import"}, + period="month", + ) + assert stats == expected_stats + # Try to get data for entities which do not exist stats = statistics_during_period( hass, @@ -1394,6 +1646,11 @@ def test_monthly_statistics( "end": oct_end.timestamp(), "sum": pytest.approx(5.0), }, + { + "start": nov_start.timestamp(), + "end": nov_end.timestamp(), + "sum": pytest.approx(7.0), + }, ] } @@ -1418,6 +1675,11 @@ def test_monthly_statistics( "end": oct_end.timestamp(), "sum": pytest.approx(5000.0), }, + { + "start": nov_start.timestamp(), + "end": nov_end.timestamp(), + "sum": pytest.approx(7000.0), + }, ] } From 52c773a776500033c3fe4c286133fd1e07ddf7b9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 May 2023 19:02:32 +0900 Subject: [PATCH 315/822] Always request at least one zone for multi-zone LIFX devices (#92683) --- homeassistant/components/lifx/coordinator.py | 47 +++++++++++++++++--- tests/components/lifx/test_light.py | 10 +++++ 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/lifx/coordinator.py b/homeassistant/components/lifx/coordinator.py index f8964e78f632..e668a7ad79ad 100644 --- a/homeassistant/components/lifx/coordinator.py +++ b/homeassistant/components/lifx/coordinator.py @@ -11,6 +11,7 @@ from typing import Any, cast from aiolifx.aiolifx import ( Light, + Message, MultiZoneDirection, MultiZoneEffectType, TileEffectType, @@ -56,6 +57,8 @@ from .util import ( LIGHT_UPDATE_INTERVAL = 10 REQUEST_REFRESH_DELAY = 0.35 LIFX_IDENTIFY_DELAY = 3.0 +ZONES_PER_COLOR_UPDATE_REQUEST = 8 + RSSI_DBM_FW = AwesomeVersion("2.77") @@ -208,18 +211,50 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator[None]): def get_number_of_zones(self) -> int: """Return the number of zones. - If the number of zones is not yet populated, return 0 + If the number of zones is not yet populated, return 1 since + the device will have a least one zone. """ - return len(self.device.color_zones) if self.device.color_zones else 0 + return len(self.device.color_zones) if self.device.color_zones else 1 @callback def _async_build_color_zones_update_requests(self) -> list[Callable]: """Build a color zones update request.""" device = self.device - return [ - partial(device.get_color_zones, start_index=zone) - for zone in range(0, self.get_number_of_zones(), 8) - ] + calls: list[Callable] = [] + for zone in range( + 0, self.get_number_of_zones(), ZONES_PER_COLOR_UPDATE_REQUEST + ): + + def _wrap_get_color_zones( + callb: Callable[[Message, dict[str, Any] | None], None], + get_color_zones_args: dict[str, Any], + ) -> None: + """Capture the callback and make sure resp_set_multizonemultizone is called before.""" + + def _wrapped_callback( + bulb: Light, + response: Message, + **kwargs: Any, + ) -> None: + # We need to call resp_set_multizonemultizone to populate + # the color_zones attribute before calling the callback + device.resp_set_multizonemultizone(response) + # Now call the original callback + callb(bulb, response, **kwargs) + + device.get_color_zones(**get_color_zones_args, callb=_wrapped_callback) + + calls.append( + partial( + _wrap_get_color_zones, + get_color_zones_args={ + "start_index": zone, + "end_index": zone + ZONES_PER_COLOR_UPDATE_REQUEST - 1, + }, + ) + ) + + return calls async def _async_update_data(self) -> None: """Fetch all device data from the api.""" diff --git a/tests/components/lifx/test_light.py b/tests/components/lifx/test_light.py index fe68bd6547a7..67bf2754d11e 100644 --- a/tests/components/lifx/test_light.py +++ b/tests/components/lifx/test_light.py @@ -1754,6 +1754,8 @@ async def test_light_strip_zones_not_populated_yet(hass: HomeAssistant) -> None: bulb.power_level = 65535 bulb.color_zones = None bulb.color = [65535, 65535, 65535, 65535] + assert bulb.get_color_zones.calls == [] + with _patch_discovery(device=bulb), _patch_config_flow_try_connect( device=bulb ), _patch_device(device=bulb): @@ -1761,6 +1763,14 @@ async def test_light_strip_zones_not_populated_yet(hass: HomeAssistant) -> None: await hass.async_block_till_done() entity_id = "light.my_bulb" + # Make sure we at least try to fetch the first zone + # to ensure we populate the zones from the 503 response + assert len(bulb.get_color_zones.calls) == 3 + # Once to populate the number of zones + assert bulb.get_color_zones.calls[0][1]["start_index"] == 0 + # Again once we know the number of zones + assert bulb.get_color_zones.calls[1][1]["start_index"] == 0 + assert bulb.get_color_zones.calls[2][1]["start_index"] == 8 state = hass.states.get(entity_id) assert state.state == "on" From aee3f115d6e7917c732936080f085dbee094f95a Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Thu, 11 May 2023 11:49:11 -0400 Subject: [PATCH 316/822] Move Netgear LTE constants to their own file (#92924) * Move Netgear LTE parts to their own files * Revert to only move constants * uno mas --- .../components/netgear_lte/__init__.py | 58 +++++++++---------- .../components/netgear_lte/binary_sensor.py | 6 +- homeassistant/components/netgear_lte/const.py | 22 +++++++ .../components/netgear_lte/notify.py | 15 ++--- .../components/netgear_lte/sensor.py | 6 +- 5 files changed, 62 insertions(+), 45 deletions(-) create mode 100644 homeassistant/components/netgear_lte/const.py diff --git a/homeassistant/components/netgear_lte/__init__.py b/homeassistant/components/netgear_lte/__init__.py index 0ab3dd07edf6..77bd83a6edd8 100644 --- a/homeassistant/components/netgear_lte/__init__.py +++ b/homeassistant/components/netgear_lte/__init__.py @@ -1,8 +1,6 @@ """Support for Netgear LTE modems.""" import asyncio from datetime import timedelta -import logging -from typing import Final import aiohttp import attr @@ -30,18 +28,24 @@ from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType from . import sensor_types - -_LOGGER = logging.getLogger(__name__) +from .const import ( + ATTR_AUTOCONNECT, + ATTR_FAILOVER, + ATTR_FROM, + ATTR_HOST, + ATTR_MESSAGE, + ATTR_SMS_ID, + AUTOCONNECT_MODES, + CONF_BINARY_SENSOR, + CONF_NOTIFY, + CONF_SENSOR, + DISPATCHER_NETGEAR_LTE, + DOMAIN, + FAILOVER_MODES, + LOGGER, +) SCAN_INTERVAL = timedelta(seconds=10) -DISPATCHER_NETGEAR_LTE = "netgear_lte_update" - -CONF_NOTIFY: Final = "notify" -CONF_BINARY_SENSOR: Final = "binary_sensor" -CONF_SENSOR: Final = "sensor" - -DOMAIN = "netgear_lte" -DATA_KEY = "netgear_lte" EVENT_SMS = "netgear_lte_sms" @@ -50,16 +54,6 @@ SERVICE_SET_OPTION = "set_option" SERVICE_CONNECT_LTE = "connect_lte" SERVICE_DISCONNECT_LTE = "disconnect_lte" -ATTR_HOST = "host" -ATTR_SMS_ID = "sms_id" -ATTR_FROM = "from" -ATTR_MESSAGE = "message" -ATTR_FAILOVER = "failover" -ATTR_AUTOCONNECT = "autoconnect" - -FAILOVER_MODES = ["auto", "wire", "mobile"] -AUTOCONNECT_MODES = ["never", "home", "always"] - NOTIFY_SCHEMA = vol.Schema( { @@ -148,11 +142,11 @@ class ModemData: try: self.data = await self.modem.information() if not self.connected: - _LOGGER.warning("Connected to %s", self.host) + LOGGER.warning("Connected to %s", self.host) self.connected = True except eternalegypt.Error: if self.connected: - _LOGGER.warning("Lost connection to %s", self.host) + LOGGER.warning("Lost connection to %s", self.host) self.connected = False self.data = None @@ -177,20 +171,20 @@ class LTEData: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Netgear LTE component.""" - if DATA_KEY not in hass.data: + if DOMAIN not in hass.data: websession = async_create_clientsession( hass, cookie_jar=aiohttp.CookieJar(unsafe=True) ) - hass.data[DATA_KEY] = LTEData(websession) + hass.data[DOMAIN] = LTEData(websession) async def service_handler(service: ServiceCall) -> None: """Apply a service.""" host = service.data.get(ATTR_HOST) conf = {CONF_HOST: host} - modem_data = hass.data[DATA_KEY].get_modem_data(conf) + modem_data = hass.data[DOMAIN].get_modem_data(conf) if not modem_data: - _LOGGER.error("%s: host %s unavailable", service.service, host) + LOGGER.error("%s: host %s unavailable", service.service, host) return if service.service == SERVICE_DELETE_SMS: @@ -272,7 +266,7 @@ async def _setup_lte(hass, lte_config): host = lte_config[CONF_HOST] password = lte_config[CONF_PASSWORD] - websession = hass.data[DATA_KEY].websession + websession = hass.data[DOMAIN].websession modem = eternalegypt.Modem(hostname=host, websession=websession) modem_data = ModemData(hass, host, modem) @@ -308,7 +302,7 @@ async def _login(hass, modem_data, password): await modem_data.modem.add_sms_listener(fire_sms_event) await modem_data.async_update() - hass.data[DATA_KEY].modem_data[modem_data.host] = modem_data + hass.data[DOMAIN].modem_data[modem_data.host] = modem_data async def _update(now): """Periodic update.""" @@ -320,7 +314,7 @@ async def _login(hass, modem_data, password): """Clean up resources.""" update_unsub() await modem_data.modem.logout() - del hass.data[DATA_KEY].modem_data[modem_data.host] + del hass.data[DOMAIN].modem_data[modem_data.host] hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, cleanup) @@ -328,7 +322,7 @@ async def _login(hass, modem_data, password): async def _retry_login(hass, modem_data, password): """Sleep and retry setup.""" - _LOGGER.warning("Could not connect to %s. Will keep trying", modem_data.host) + LOGGER.warning("Could not connect to %s. Will keep trying", modem_data.host) modem_data.connected = False delay = 15 diff --git a/homeassistant/components/netgear_lte/binary_sensor.py b/homeassistant/components/netgear_lte/binary_sensor.py index 0e7cd14bea6e..7fb119f3dee9 100644 --- a/homeassistant/components/netgear_lte/binary_sensor.py +++ b/homeassistant/components/netgear_lte/binary_sensor.py @@ -2,12 +2,14 @@ from __future__ import annotations from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.const import CONF_MONITORED_CONDITIONS from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import CONF_BINARY_SENSOR, CONF_MONITORED_CONDITIONS, DATA_KEY, LTEEntity +from . import LTEEntity +from .const import CONF_BINARY_SENSOR, DOMAIN from .sensor_types import BINARY_SENSOR_CLASSES @@ -21,7 +23,7 @@ async def async_setup_platform( if discovery_info is None: return - modem_data = hass.data[DATA_KEY].get_modem_data(discovery_info) + modem_data = hass.data[DOMAIN].get_modem_data(discovery_info) if not modem_data or not modem_data.data: raise PlatformNotReady diff --git a/homeassistant/components/netgear_lte/const.py b/homeassistant/components/netgear_lte/const.py new file mode 100644 index 000000000000..12c8f06b695c --- /dev/null +++ b/homeassistant/components/netgear_lte/const.py @@ -0,0 +1,22 @@ +"""Constants for the Netgear LTE integration.""" +import logging +from typing import Final + +ATTR_HOST = "host" +ATTR_SMS_ID = "sms_id" +ATTR_FROM = "from" +ATTR_MESSAGE = "message" +ATTR_FAILOVER = "failover" +ATTR_AUTOCONNECT = "autoconnect" +AUTOCONNECT_MODES = ["never", "home", "always"] + +CONF_BINARY_SENSOR: Final = "binary_sensor" +CONF_NOTIFY: Final = "notify" +CONF_SENSOR: Final = "sensor" + +DISPATCHER_NETGEAR_LTE = "netgear_lte_update" +DOMAIN: Final = "netgear_lte" + +FAILOVER_MODES = ["auto", "wire", "mobile"] + +LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/netgear_lte/notify.py b/homeassistant/components/netgear_lte/notify.py index 52ce3a85dcb8..c21b56799eb7 100644 --- a/homeassistant/components/netgear_lte/notify.py +++ b/homeassistant/components/netgear_lte/notify.py @@ -1,18 +1,15 @@ """Support for Netgear LTE notifications.""" from __future__ import annotations -import logging - import attr import eternalegypt from homeassistant.components.notify import ATTR_TARGET, BaseNotificationService +from homeassistant.const import CONF_RECIPIENT from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import CONF_NOTIFY, CONF_RECIPIENT, DATA_KEY - -_LOGGER = logging.getLogger(__name__) +from .const import CONF_NOTIFY, DOMAIN, LOGGER async def async_get_service( @@ -37,14 +34,14 @@ class NetgearNotifyService(BaseNotificationService): async def async_send_message(self, message="", **kwargs): """Send a message to a user.""" - modem_data = self.hass.data[DATA_KEY].get_modem_data(self.config) + modem_data = self.hass.data[DOMAIN].get_modem_data(self.config) if not modem_data: - _LOGGER.error("Modem not ready") + LOGGER.error("Modem not ready") return targets = kwargs.get(ATTR_TARGET, self.config[CONF_NOTIFY][CONF_RECIPIENT]) if not targets: - _LOGGER.warning("No recipients") + LOGGER.warning("No recipients") return if not message: @@ -54,4 +51,4 @@ class NetgearNotifyService(BaseNotificationService): try: await modem_data.modem.sms(target, message) except eternalegypt.Error: - _LOGGER.error("Unable to send to %s", target) + LOGGER.error("Unable to send to %s", target) diff --git a/homeassistant/components/netgear_lte/sensor.py b/homeassistant/components/netgear_lte/sensor.py index 49942306da26..27ad0ffe67b3 100644 --- a/homeassistant/components/netgear_lte/sensor.py +++ b/homeassistant/components/netgear_lte/sensor.py @@ -2,12 +2,14 @@ from __future__ import annotations from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.const import CONF_MONITORED_CONDITIONS from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import CONF_MONITORED_CONDITIONS, CONF_SENSOR, DATA_KEY, LTEEntity +from . import LTEEntity +from .const import CONF_SENSOR, DOMAIN from .sensor_types import SENSOR_SMS, SENSOR_SMS_TOTAL, SENSOR_UNITS, SENSOR_USAGE @@ -21,7 +23,7 @@ async def async_setup_platform( if discovery_info is None: return - modem_data = hass.data[DATA_KEY].get_modem_data(discovery_info) + modem_data = hass.data[DOMAIN].get_modem_data(discovery_info) if not modem_data or not modem_data.data: raise PlatformNotReady From 0fcf8f968b6235e227fc0e87a6706ca01a9939c7 Mon Sep 17 00:00:00 2001 From: Ian Date: Thu, 11 May 2023 09:28:29 -0700 Subject: [PATCH 317/822] Move nextbus constants and utils (#92211) * NextBus: Move general components to const and util * Remove not yet used code --- homeassistant/components/nextbus/const.py | 6 ++++ homeassistant/components/nextbus/sensor.py | 32 ++-------------------- homeassistant/components/nextbus/util.py | 25 +++++++++++++++++ 3 files changed, 34 insertions(+), 29 deletions(-) create mode 100644 homeassistant/components/nextbus/const.py create mode 100644 homeassistant/components/nextbus/util.py diff --git a/homeassistant/components/nextbus/const.py b/homeassistant/components/nextbus/const.py new file mode 100644 index 000000000000..9d9d0a5262fd --- /dev/null +++ b/homeassistant/components/nextbus/const.py @@ -0,0 +1,6 @@ +"""NextBus Constants.""" +DOMAIN = "nextbus" + +CONF_AGENCY = "agency" +CONF_ROUTE = "route" +CONF_STOP = "stop" diff --git a/homeassistant/components/nextbus/sensor.py b/homeassistant/components/nextbus/sensor.py index 4f24a7aa7f3b..02f5d8695ca1 100644 --- a/homeassistant/components/nextbus/sensor.py +++ b/homeassistant/components/nextbus/sensor.py @@ -19,14 +19,11 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback 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 .util import listify, maybe_first + _LOGGER = logging.getLogger(__name__) -DOMAIN = "nextbus" - -CONF_AGENCY = "agency" -CONF_ROUTE = "route" -CONF_STOP = "stop" - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_AGENCY): cv.string, @@ -37,29 +34,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def listify(maybe_list): - """Return list version of whatever value is passed in. - - This is used to provide a consistent way of interacting with the JSON - results from the API. There are several attributes that will either missing - if there are no values, a single dictionary if there is only one value, and - a list if there are multiple. - """ - if maybe_list is None: - return [] - if isinstance(maybe_list, list): - return maybe_list - return [maybe_list] - - -def maybe_first(maybe_list): - """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] - - return maybe_list - - 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} diff --git a/homeassistant/components/nextbus/util.py b/homeassistant/components/nextbus/util.py new file mode 100644 index 000000000000..c753c452546a --- /dev/null +++ b/homeassistant/components/nextbus/util.py @@ -0,0 +1,25 @@ +"""Utils for NextBus integration module.""" +from typing import Any + + +def listify(maybe_list: Any) -> list[Any]: + """Return list version of whatever value is passed in. + + This is used to provide a consistent way of interacting with the JSON + results from the API. There are several attributes that will either missing + if there are no values, a single dictionary if there is only one value, and + a list if there are multiple. + """ + if maybe_list is None: + return [] + if isinstance(maybe_list, list): + return maybe_list + return [maybe_list] + + +def maybe_first(maybe_list: list[Any]) -> 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] + + return maybe_list From 30c35e73983ede5519badf6288e3b70be30fa050 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 11 May 2023 21:31:17 +0200 Subject: [PATCH 318/822] Bump python-vehicle to 1.0.1 (#92933) --- homeassistant/components/rdw/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/rdw/manifest.json b/homeassistant/components/rdw/manifest.json index 5ec3a6ae1903..0b5640fe3a41 100644 --- a/homeassistant/components/rdw/manifest.json +++ b/homeassistant/components/rdw/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["vehicle==1.0.0"] + "requirements": ["vehicle==1.0.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index db36062ce8bd..ea3c898414db 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2582,7 +2582,7 @@ uvcclient==0.11.0 vallox-websocket-api==3.2.1 # homeassistant.components.rdw -vehicle==1.0.0 +vehicle==1.0.1 # homeassistant.components.velbus velbus-aio==2023.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b1632f507b53..44456d831227 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1870,7 +1870,7 @@ uvcclient==0.11.0 vallox-websocket-api==3.2.1 # homeassistant.components.rdw -vehicle==1.0.0 +vehicle==1.0.1 # homeassistant.components.velbus velbus-aio==2023.2.0 From 809f394ff5425ca93876fe9bea75614a82b50044 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 12 May 2023 04:37:16 +0900 Subject: [PATCH 319/822] Bump sqlalchemy to 2.0.13 (#92936) --- homeassistant/components/recorder/manifest.json | 2 +- homeassistant/components/sql/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index 85190e25f4a1..a932131cb03b 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.12", + "sqlalchemy==2.0.13", "fnv-hash-fast==0.3.1", "psutil-home-assistant==0.0.1" ] diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index 0a70d9e2795a..fd48bd100945 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.12"] + "requirements": ["sqlalchemy==2.0.13"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 79898ec89b93..396e91b715af 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -45,7 +45,7 @@ pyudev==0.23.2 pyyaml==6.0 requests==2.29.0 scapy==2.5.0 -sqlalchemy==2.0.12 +sqlalchemy==2.0.13 typing-extensions>=4.5.0,<5.0 ulid-transform==0.7.2 voluptuous-serialize==2.6.0 diff --git a/requirements_all.txt b/requirements_all.txt index ea3c898414db..cacf2ec8d5cb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2406,7 +2406,7 @@ spotipy==2.23.0 # homeassistant.components.recorder # homeassistant.components.sql -sqlalchemy==2.0.12 +sqlalchemy==2.0.13 # homeassistant.components.srp_energy srpenergy==1.3.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 44456d831227..461473af6469 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1742,7 +1742,7 @@ spotipy==2.23.0 # homeassistant.components.recorder # homeassistant.components.sql -sqlalchemy==2.0.12 +sqlalchemy==2.0.13 # homeassistant.components.srp_energy srpenergy==1.3.6 From e9705364a80fff9c18e2e24b0c0dceff0a71df6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Fri, 12 May 2023 09:07:29 +0200 Subject: [PATCH 320/822] Fix Airzone Auto operation mode (#92796) --- homeassistant/components/airzone/climate.py | 30 +++++++------- .../components/airzone/test_binary_sensor.py | 6 +++ tests/components/airzone/test_climate.py | 18 ++++++++ tests/components/airzone/test_sensor.py | 6 +++ tests/components/airzone/util.py | 41 +++++++++++++++++++ 5 files changed, 86 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/airzone/climate.py b/homeassistant/components/airzone/climate.py index c344b1ff49cd..1a167f49f78e 100644 --- a/homeassistant/components/airzone/climate.py +++ b/homeassistant/components/airzone/climate.py @@ -3,12 +3,12 @@ from __future__ import annotations from typing import Any, Final -from aioairzone.common import OperationMode +from aioairzone.common import OperationAction, OperationMode from aioairzone.const import ( API_MODE, API_ON, API_SET_POINT, - AZD_DEMAND, + AZD_ACTION, AZD_HUMIDITY, AZD_MASTER, AZD_MODE, @@ -39,12 +39,13 @@ from .const import API_TEMPERATURE_STEP, DOMAIN, TEMP_UNIT_LIB_TO_HASS from .coordinator import AirzoneUpdateCoordinator from .entity import AirzoneZoneEntity -HVAC_ACTION_LIB_TO_HASS: Final[dict[OperationMode, HVACAction]] = { - OperationMode.STOP: HVACAction.OFF, - OperationMode.COOLING: HVACAction.COOLING, - OperationMode.HEATING: HVACAction.HEATING, - OperationMode.FAN: HVACAction.FAN, - OperationMode.DRY: HVACAction.DRYING, +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, @@ -156,14 +157,13 @@ class AirzoneClimate(AirzoneZoneEntity, ClimateEntity): """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_ON): - mode = self.get_airzone_value(AZD_MODE) - self._attr_hvac_mode = HVAC_MODE_LIB_TO_HASS[mode] - if self.get_airzone_value(AZD_DEMAND): - self._attr_hvac_action = HVAC_ACTION_LIB_TO_HASS[mode] - else: - self._attr_hvac_action = HVACAction.IDLE + self._attr_hvac_mode = HVAC_MODE_LIB_TO_HASS[ + self.get_airzone_value(AZD_MODE) + ] else: - self._attr_hvac_action = HVACAction.OFF self._attr_hvac_mode = HVACMode.OFF self._attr_target_temperature = self.get_airzone_value(AZD_TEMP_SET) diff --git a/tests/components/airzone/test_binary_sensor.py b/tests/components/airzone/test_binary_sensor.py index 860bc50e93ca..8033871f5c3b 100644 --- a/tests/components/airzone/test_binary_sensor.py +++ b/tests/components/airzone/test_binary_sensor.py @@ -84,3 +84,9 @@ async def test_airzone_create_binary_sensors(hass: HomeAssistant) -> None: state = hass.states.get("binary_sensor.airzone_2_1_problem") assert state.state == STATE_OFF + + state = hass.states.get("binary_sensor.dkn_plus_battery_low") + assert state is None + + state = hass.states.get("binary_sensor.dkn_plus_problem") + assert state.state == STATE_OFF diff --git a/tests/components/airzone/test_climate.py b/tests/components/airzone/test_climate.py index 640826bb30ff..caf8cfe13bd8 100644 --- a/tests/components/airzone/test_climate.py +++ b/tests/components/airzone/test_climate.py @@ -145,6 +145,24 @@ async def test_airzone_create_climates(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_TARGET_TEMP_STEP) == API_TEMPERATURE_STEP assert state.attributes.get(ATTR_TEMPERATURE) == 19.0 + state = hass.states.get("climate.dkn_plus") + assert state.state == HVACMode.HEAT_COOL + assert state.attributes.get(ATTR_CURRENT_HUMIDITY) is None + assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == 21.7 + assert state.attributes.get(ATTR_HVAC_ACTION) == HVACAction.COOLING + assert state.attributes.get(ATTR_HVAC_MODES) == [ + HVACMode.FAN_ONLY, + HVACMode.COOL, + HVACMode.HEAT, + HVACMode.DRY, + HVACMode.HEAT_COOL, + HVACMode.OFF, + ] + assert state.attributes.get(ATTR_MAX_TEMP) == 32.2 + assert state.attributes.get(ATTR_MIN_TEMP) == 17.8 + assert state.attributes.get(ATTR_TARGET_TEMP_STEP) == API_TEMPERATURE_STEP + assert state.attributes.get(ATTR_TEMPERATURE) == 22.8 + async def test_airzone_climate_turn_on_off(hass: HomeAssistant) -> None: """Test turning on.""" diff --git a/tests/components/airzone/test_sensor.py b/tests/components/airzone/test_sensor.py index 1e7d335a46fa..c72c083039e1 100644 --- a/tests/components/airzone/test_sensor.py +++ b/tests/components/airzone/test_sensor.py @@ -52,3 +52,9 @@ async def test_airzone_create_sensors( state = hass.states.get("sensor.airzone_2_1_humidity") assert state.state == "62" + + state = hass.states.get("sensor.dkn_plus_temperature") + assert state.state == "21.7" + + state = hass.states.get("sensor.dkn_plus_humidity") + assert state is None diff --git a/tests/components/airzone/util.py b/tests/components/airzone/util.py index 6277c077c006..bbbe00a431b6 100644 --- a/tests/components/airzone/util.py +++ b/tests/components/airzone/util.py @@ -7,10 +7,16 @@ from aioairzone.const import ( API_COLD_ANGLE, API_COLD_STAGE, API_COLD_STAGES, + API_COOL_MAX_TEMP, + API_COOL_MIN_TEMP, + API_COOL_SET_POINT, API_DATA, API_ERRORS, API_FLOOR_DEMAND, API_HEAT_ANGLE, + API_HEAT_MAX_TEMP, + API_HEAT_MIN_TEMP, + API_HEAT_SET_POINT, API_HEAT_STAGE, API_HEAT_STAGES, API_HUMIDITY, @@ -25,6 +31,8 @@ from aioairzone.const import ( API_ROOM_TEMP, API_SET_POINT, API_SLEEP, + API_SPEED, + API_SPEEDS, API_SYSTEM_FIRMWARE, API_SYSTEM_ID, API_SYSTEM_TYPE, @@ -216,6 +224,39 @@ HVAC_MOCK = { }, ] }, + { + API_DATA: [ + { + API_SYSTEM_ID: 3, + API_ZONE_ID: 1, + API_NAME: "DKN Plus", + API_ON: 1, + API_COOL_SET_POINT: 73, + API_COOL_MAX_TEMP: 90, + API_COOL_MIN_TEMP: 64, + API_HEAT_SET_POINT: 77, + API_HEAT_MAX_TEMP: 86, + API_HEAT_MIN_TEMP: 50, + API_MAX_TEMP: 90, + API_MIN_TEMP: 64, + API_SET_POINT: 73, + API_ROOM_TEMP: 71, + API_MODES: [4, 2, 3, 5, 7], + API_MODE: 7, + API_SPEEDS: 5, + API_SPEED: 2, + API_COLD_STAGES: 0, + API_COLD_STAGE: 0, + API_HEAT_STAGES: 0, + API_HEAT_STAGE: 0, + API_HUMIDITY: 0, + API_UNITS: 1, + API_ERRORS: [], + API_AIR_DEMAND: 1, + API_FLOOR_DEMAND: 0, + }, + ] + }, ] } From b4e85b769267f008be9f272cfe1bcf474df2c63e Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Fri, 12 May 2023 07:29:56 -0400 Subject: [PATCH 321/822] Move Netgear LTE entity to its own file (#92944) --- .../components/netgear_lte/__init__.py | 53 +------------------ .../components/netgear_lte/binary_sensor.py | 2 +- .../components/netgear_lte/entity.py | 41 ++++++++++++++ .../components/netgear_lte/sensor.py | 2 +- 4 files changed, 44 insertions(+), 54 deletions(-) create mode 100644 homeassistant/components/netgear_lte/entity.py diff --git a/homeassistant/components/netgear_lte/__init__.py b/homeassistant/components/netgear_lte/__init__.py index 77bd83a6edd8..14aee2296173 100644 --- a/homeassistant/components/netgear_lte/__init__.py +++ b/homeassistant/components/netgear_lte/__init__.py @@ -19,11 +19,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.aiohttp_client import async_create_clientsession -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, - async_dispatcher_send, -) -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType @@ -334,50 +330,3 @@ async def _retry_login(hass, modem_data, password): await _login(hass, modem_data, password) except eternalegypt.Error: delay = min(2 * delay, 300) - - -@attr.s -class LTEEntity(Entity): - """Base LTE entity.""" - - modem_data = attr.ib() - sensor_type = attr.ib() - - _unique_id = attr.ib(init=False) - - @_unique_id.default - def _init_unique_id(self): - """Register unique_id while we know data is valid.""" - return f"{self.sensor_type}_{self.modem_data.data.serial_number}" - - async def async_added_to_hass(self): - """Register callback.""" - self.async_on_remove( - async_dispatcher_connect( - self.hass, DISPATCHER_NETGEAR_LTE, self.async_write_ha_state - ) - ) - - async def async_update(self): - """Force update of state.""" - await self.modem_data.async_update() - - @property - def should_poll(self): - """Return that the sensor should not be polled.""" - return False - - @property - def available(self): - """Return the availability of the sensor.""" - return self.modem_data.data is not None - - @property - def unique_id(self): - """Return a unique ID like 'usage_5TG365AB0078V'.""" - return self._unique_id - - @property - def name(self): - """Return the name of the sensor.""" - return f"Netgear LTE {self.sensor_type}" diff --git a/homeassistant/components/netgear_lte/binary_sensor.py b/homeassistant/components/netgear_lte/binary_sensor.py index 7fb119f3dee9..add59096024c 100644 --- a/homeassistant/components/netgear_lte/binary_sensor.py +++ b/homeassistant/components/netgear_lte/binary_sensor.py @@ -8,8 +8,8 @@ from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import LTEEntity from .const import CONF_BINARY_SENSOR, DOMAIN +from .entity import LTEEntity from .sensor_types import BINARY_SENSOR_CLASSES diff --git a/homeassistant/components/netgear_lte/entity.py b/homeassistant/components/netgear_lte/entity.py new file mode 100644 index 000000000000..33e0aaab749a --- /dev/null +++ b/homeassistant/components/netgear_lte/entity.py @@ -0,0 +1,41 @@ +"""Entity representing a Netgear LTE entity.""" + +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity + +from . import ModemData +from .const import DISPATCHER_NETGEAR_LTE + + +class LTEEntity(Entity): + """Base LTE entity.""" + + _attr_should_poll = False + + def __init__( + self, + modem_data: ModemData, + sensor_type: str, + ) -> None: + """Initialize a Netgear LTE entity.""" + self.modem_data = modem_data + self.sensor_type = sensor_type + self._attr_name = f"Netgear LTE {sensor_type}" + self._attr_unique_id = f"{sensor_type}_{modem_data.data.serial_number}" + + async def async_added_to_hass(self) -> None: + """Register callback.""" + self.async_on_remove( + async_dispatcher_connect( + self.hass, DISPATCHER_NETGEAR_LTE, self.async_write_ha_state + ) + ) + + async def async_update(self) -> None: + """Force update of state.""" + await self.modem_data.async_update() + + @property + def available(self) -> bool: + """Return the availability of the sensor.""" + return self.modem_data.data is not None diff --git a/homeassistant/components/netgear_lte/sensor.py b/homeassistant/components/netgear_lte/sensor.py index 27ad0ffe67b3..4ca127e57241 100644 --- a/homeassistant/components/netgear_lte/sensor.py +++ b/homeassistant/components/netgear_lte/sensor.py @@ -8,8 +8,8 @@ from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import LTEEntity from .const import CONF_SENSOR, DOMAIN +from .entity import LTEEntity from .sensor_types import SENSOR_SMS, SENSOR_SMS_TOTAL, SENSOR_UNITS, SENSOR_USAGE From 7546e8efa4ac7e34169bbd8cc01b6e106dc9504b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 12 May 2023 14:34:29 +0200 Subject: [PATCH 322/822] Remove stale comment from sensor.const (#92974) --- homeassistant/components/sensor/const.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index e829c8a8e494..4764de527502 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -443,8 +443,6 @@ STATE_CLASS_TOTAL: Final = "total" STATE_CLASS_TOTAL_INCREASING: Final = "total_increasing" STATE_CLASSES: Final[list[str]] = [cls.value for cls in SensorStateClass] -# Note: this needs to be aligned with frontend: OVERRIDE_SENSOR_UNITS in -# `entity-registry-settings.ts` UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] = { SensorDeviceClass.ATMOSPHERIC_PRESSURE: PressureConverter, SensorDeviceClass.CURRENT: ElectricCurrentConverter, From 5a7c3aaa2ded2db7457855d726adbec1390949e6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 12 May 2023 14:37:54 +0200 Subject: [PATCH 323/822] Add sensor device class volatile_organic_compounds_parts (#92973) --- homeassistant/components/number/const.py | 11 +++++++++++ homeassistant/components/sensor/const.py | 12 ++++++++++++ homeassistant/components/sensor/device_condition.py | 5 +++++ homeassistant/components/sensor/device_trigger.py | 5 +++++ homeassistant/components/sensor/strings.json | 5 +++++ tests/components/sensor/test_init.py | 1 + 6 files changed, 39 insertions(+) diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index 48cd04dc26e8..b0542aa588a8 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -8,6 +8,7 @@ import voluptuous as vol from homeassistant.backports.enum import StrEnum from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, LIGHT_LUX, PERCENTAGE, @@ -309,6 +310,12 @@ class NumberDeviceClass(StrEnum): Unit of measurement: `µg/m³` """ + VOLATILE_ORGANIC_COMPOUNDS_PARTS = "volatile_organic_compounds_parts" + """Ratio of VOC. + + Unit of measurement: `ppm`, `ppb` + """ + VOLTAGE = "voltage" """Voltage. @@ -421,6 +428,10 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = { NumberDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: { CONCENTRATION_MICROGRAMS_PER_CUBIC_METER }, + NumberDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS: { + CONCENTRATION_PARTS_PER_BILLION, + CONCENTRATION_PARTS_PER_MILLION, + }, NumberDeviceClass.VOLTAGE: set(UnitOfElectricPotential), NumberDeviceClass.VOLUME: set(UnitOfVolume), NumberDeviceClass.VOLUME_STORAGE: set(UnitOfVolume), diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index 4764de527502..8ab90f03c014 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -8,6 +8,7 @@ import voluptuous as vol from homeassistant.backports.enum import StrEnum from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, LIGHT_LUX, PERCENTAGE, @@ -347,6 +348,12 @@ class SensorDeviceClass(StrEnum): Unit of measurement: `µg/m³` """ + VOLATILE_ORGANIC_COMPOUNDS_PARTS = "volatile_organic_compounds_parts" + """Ratio of VOC. + + Unit of measurement: `ppm`, `ppb` + """ + VOLTAGE = "voltage" """Voltage. @@ -521,6 +528,10 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = { SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: { CONCENTRATION_MICROGRAMS_PER_CUBIC_METER }, + SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS: { + CONCENTRATION_PARTS_PER_BILLION, + CONCENTRATION_PARTS_PER_MILLION, + }, SensorDeviceClass.VOLTAGE: set(UnitOfElectricPotential), SensorDeviceClass.VOLUME: set(UnitOfVolume), SensorDeviceClass.WATER: { @@ -580,6 +591,7 @@ DEVICE_CLASS_STATE_CLASSES: dict[SensorDeviceClass, set[SensorStateClass]] = { SensorDeviceClass.TEMPERATURE: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.TIMESTAMP: set(), SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: {SensorStateClass.MEASUREMENT}, + SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.VOLTAGE: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.VOLUME: { SensorStateClass.TOTAL, diff --git a/homeassistant/components/sensor/device_condition.py b/homeassistant/components/sensor/device_condition.py index 8547827d7488..c52e076e51ea 100644 --- a/homeassistant/components/sensor/device_condition.py +++ b/homeassistant/components/sensor/device_condition.py @@ -70,6 +70,7 @@ CONF_IS_SULPHUR_DIOXIDE = "is_sulphur_dioxide" CONF_IS_TEMPERATURE = "is_temperature" CONF_IS_VALUE = "is_value" CONF_IS_VOLATILE_ORGANIC_COMPOUNDS = "is_volatile_organic_compounds" +CONF_IS_VOLATILE_ORGANIC_COMPOUNDS_PARTS = "is_volatile_organic_compounds_parts" CONF_IS_VOLTAGE = "is_voltage" CONF_IS_VOLUME = "is_volume" CONF_IS_WATER = "is_water" @@ -120,6 +121,9 @@ ENTITY_CONDITIONS = { SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: [ {CONF_TYPE: CONF_IS_VOLATILE_ORGANIC_COMPOUNDS} ], + SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS: [ + {CONF_TYPE: CONF_IS_VOLATILE_ORGANIC_COMPOUNDS_PARTS} + ], SensorDeviceClass.VOLTAGE: [{CONF_TYPE: CONF_IS_VOLTAGE}], SensorDeviceClass.VOLUME: [{CONF_TYPE: CONF_IS_VOLUME}], SensorDeviceClass.VOLUME_STORAGE: [{CONF_TYPE: CONF_IS_VOLUME}], @@ -173,6 +177,7 @@ CONDITION_SCHEMA = vol.All( CONF_IS_SULPHUR_DIOXIDE, CONF_IS_TEMPERATURE, CONF_IS_VOLATILE_ORGANIC_COMPOUNDS, + CONF_IS_VOLATILE_ORGANIC_COMPOUNDS_PARTS, CONF_IS_VOLTAGE, CONF_IS_VOLUME, CONF_IS_WATER, diff --git a/homeassistant/components/sensor/device_trigger.py b/homeassistant/components/sensor/device_trigger.py index 3b2a0485554a..0d594e1b7c3a 100644 --- a/homeassistant/components/sensor/device_trigger.py +++ b/homeassistant/components/sensor/device_trigger.py @@ -69,6 +69,7 @@ CONF_SULPHUR_DIOXIDE = "sulphur_dioxide" CONF_TEMPERATURE = "temperature" CONF_VALUE = "value" CONF_VOLATILE_ORGANIC_COMPOUNDS = "volatile_organic_compounds" +CONF_VOLATILE_ORGANIC_COMPOUNDS_PARTS = "volatile_organic_compounds_parts" CONF_VOLTAGE = "voltage" CONF_VOLUME = "volume" CONF_WATER = "water" @@ -119,6 +120,9 @@ ENTITY_TRIGGERS = { SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: [ {CONF_TYPE: CONF_VOLATILE_ORGANIC_COMPOUNDS} ], + SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS: [ + {CONF_TYPE: CONF_VOLATILE_ORGANIC_COMPOUNDS_PARTS} + ], SensorDeviceClass.VOLTAGE: [{CONF_TYPE: CONF_VOLTAGE}], SensorDeviceClass.VOLUME: [{CONF_TYPE: CONF_VOLUME}], SensorDeviceClass.VOLUME_STORAGE: [{CONF_TYPE: CONF_VOLUME}], @@ -173,6 +177,7 @@ TRIGGER_SCHEMA = vol.All( CONF_SULPHUR_DIOXIDE, CONF_TEMPERATURE, CONF_VOLATILE_ORGANIC_COMPOUNDS, + CONF_VOLATILE_ORGANIC_COMPOUNDS_PARTS, CONF_VOLTAGE, CONF_VOLUME, CONF_WATER, diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index 52792e1d1f28..c4c1f81109d4 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -41,6 +41,7 @@ "is_temperature": "Current {entity_name} temperature", "is_value": "Current {entity_name} value", "is_volatile_organic_compounds": "Current {entity_name} volatile organic compounds concentration level", + "is_volatile_organic_compounds_parts": "[%key:component::sensor::device_automation::condition_type::is_volatile_organic_compounds%]", "is_voltage": "Current {entity_name} voltage", "is_volume": "Current {entity_name} volume", "is_water": "Current {entity_name} water", @@ -87,6 +88,7 @@ "temperature": "{entity_name} temperature changes", "value": "{entity_name} value changes", "volatile_organic_compounds": "{entity_name} volatile organic compounds concentration changes", + "volatile_organic_compounds_parts": "[%key:component::sensor::device_automation::trigger_type::volatile_organic_compounds%]", "voltage": "{entity_name} voltage changes", "volume": "{entity_name} volume changes", "water": "{entity_name} water changes", @@ -244,6 +246,9 @@ "volatile_organic_compounds": { "name": "VOCs" }, + "volatile_organic_compounds_parts": { + "name": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]" + }, "voltage": { "name": "Voltage" }, diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index 82ea25b5a11c..7c637b0a6f2b 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -1758,6 +1758,7 @@ async def test_non_numeric_device_class_with_unit_of_measurement( SensorDeviceClass.SULPHUR_DIOXIDE, SensorDeviceClass.TEMPERATURE, SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS, SensorDeviceClass.VOLTAGE, SensorDeviceClass.VOLUME, SensorDeviceClass.WATER, From 24284fe379e442d1c3a8e109cf3fd4627b25312b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 12 May 2023 21:57:51 +0900 Subject: [PATCH 324/822] Speed up validating domains in templates (#92975) This path gets called quite a bit since most templates access the state via states.DOMAIN... --- homeassistant/core.py | 12 +++++++++++- homeassistant/helpers/template.py | 3 ++- tests/test_core.py | 26 ++++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index 48c69f35956b..2fed56644e41 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -161,9 +161,19 @@ def split_entity_id(entity_id: str) -> tuple[str, str]: return domain, object_id -VALID_ENTITY_ID = re.compile(r"^(?!.+__)(?!_)[\da-z_]+(? bool: + """Test if a domain a valid format.""" + return VALID_DOMAIN.match(domain) is not None + + +@functools.lru_cache(64) def valid_entity_id(entity_id: str) -> bool: """Test if an entity ID is a valid format. diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 5cc2c6aa8077..267e2e6c5656 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -63,6 +63,7 @@ from homeassistant.core import ( State, callback, split_entity_id, + valid_domain, valid_entity_id, ) from homeassistant.exceptions import TemplateError @@ -796,7 +797,7 @@ class AllStates: if name in _RESERVED_NAMES: return None - if not valid_entity_id(f"{name}.entity"): + if not valid_domain(name): raise TemplateError(f"Invalid domain name '{name}'") return _domain_states(self._hass, name) diff --git a/tests/test_core.py b/tests/test_core.py index 4d7a93c28873..11715952126d 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1384,6 +1384,32 @@ def test_valid_entity_id() -> None: assert ha.valid_entity_id(valid), valid +def test_valid_domain() -> None: + """Test valid domain.""" + for invalid in [ + "_light", + ".kitchen", + ".light.kitchen", + "light_.kitchen", + "._kitchen", + "light.", + "light.kitchen__ceiling", + "light.kitchen_yo_", + "light.kitchen.", + "Light", + ]: + assert not ha.valid_domain(invalid), invalid + + for valid in [ + "1", + "1light", + "a", + "input_boolean", + "light", + ]: + assert ha.valid_domain(valid), valid + + async def test_additional_data_in_core_config( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: From 02b5d643e76fb01ab779ea836c619554d50ba94e Mon Sep 17 00:00:00 2001 From: Piotr Tobolski Date: Fri, 12 May 2023 15:03:06 +0200 Subject: [PATCH 325/822] Add unique_id support to history_stats sensor (#92972) --- .../components/history_stats/sensor.py | 7 +++++- tests/components/history_stats/test_sensor.py | 23 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/history_stats/sensor.py b/homeassistant/components/history_stats/sensor.py index 2b02be17e9aa..5b1242423c74 100644 --- a/homeassistant/components/history_stats/sensor.py +++ b/homeassistant/components/history_stats/sensor.py @@ -17,6 +17,7 @@ from homeassistant.const import ( CONF_NAME, CONF_STATE, CONF_TYPE, + CONF_UNIQUE_ID, PERCENTAGE, UnitOfTime, ) @@ -72,6 +73,7 @@ PLATFORM_SCHEMA = vol.All( vol.Optional(CONF_DURATION): cv.time_period, vol.Optional(CONF_TYPE, default=CONF_TYPE_TIME): vol.In(CONF_TYPE_KEYS), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_UNIQUE_ID): cv.string, } ), exactly_two_period_keys, @@ -95,6 +97,7 @@ async def async_setup_platform( duration: datetime.timedelta | None = config.get(CONF_DURATION) sensor_type: str = config[CONF_TYPE] name: str = config[CONF_NAME] + unique_id: str | None = config.get(CONF_UNIQUE_ID) for template in (start, end): if template is not None: @@ -105,7 +108,7 @@ async def async_setup_platform( await coordinator.async_refresh() if not coordinator.last_update_success: raise PlatformNotReady from coordinator.last_exception - async_add_entities([HistoryStatsSensor(coordinator, sensor_type, name)]) + async_add_entities([HistoryStatsSensor(coordinator, sensor_type, name, unique_id)]) class HistoryStatsSensorBase( @@ -150,11 +153,13 @@ class HistoryStatsSensor(HistoryStatsSensorBase): coordinator: HistoryStatsUpdateCoordinator, sensor_type: str, name: str, + unique_id: str | None, ) -> None: """Initialize the HistoryStats sensor.""" super().__init__(coordinator, name) self._attr_native_unit_of_measurement = UNITS[sensor_type] self._type = sensor_type + self._attr_unique_id = unique_id self._process_update() if self._type == CONF_TYPE_TIME: self._attr_device_class = SensorDeviceClass.DURATION diff --git a/tests/components/history_stats/test_sensor.py b/tests/components/history_stats/test_sensor.py index 141c0adb68f6..28e24b587aaa 100644 --- a/tests/components/history_stats/test_sensor.py +++ b/tests/components/history_stats/test_sensor.py @@ -15,6 +15,7 @@ from homeassistant.components.recorder import Recorder from homeassistant.const import ATTR_DEVICE_CLASS, SERVICE_RELOAD, STATE_UNKNOWN import homeassistant.core as ha from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_component import async_update_entity from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -1589,3 +1590,25 @@ async def test_history_stats_handles_floored_timestamps( await hass.async_block_till_done() assert last_times == (start_time, start_time + timedelta(hours=2)) + + +async def test_unique_id(recorder_mock: Recorder, hass: HomeAssistant) -> None: + """Test unique_id property.""" + + config = { + "sensor": { + "platform": "history_stats", + "entity_id": "binary_sensor.test_id", + "state": "on", + "start": "{{ utcnow() }}", + "duration": "01:00", + "name": "Test", + "unique_id": "some_history_stats_unique_id", + }, + } + + assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + + registry = er.async_get(hass) + assert registry.async_get("sensor.test").unique_id == "some_history_stats_unique_id" From 93bfd7d8d0912fbe2a2b0c945e9673696b7b25b1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 12 May 2023 22:04:09 +0900 Subject: [PATCH 326/822] Small speed up to validating entity ids (#92970) --- homeassistant/helpers/config_validation.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 0f53c9108c89..e0924b928ca5 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -567,6 +567,10 @@ def string(value: Any) -> str: if value is None: raise vol.Invalid("string value is None") + # This is expected to be the most common case, so check it first. + if type(value) is str: # pylint: disable=unidiomatic-typecheck + return value + if isinstance(value, template_helper.ResultWrapper): value = value.render_result From 30f52b8d966de0b9d5768ed4ff11ae12365ed3d7 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 12 May 2023 15:17:01 +0200 Subject: [PATCH 327/822] Minor typing adjustment in entity_platform (#92978) --- homeassistant/helpers/entity_platform.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 25140d0516f0..4a2ff020fe28 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -622,10 +622,12 @@ class EntityPlatform: except RequiredParameterMissing: pass - if entity.entity_id is not None: + # An entity may suggest the entity_id by setting entity_id itself + suggested_entity_id: str | None = entity.entity_id + if suggested_entity_id is not None: suggested_object_id = split_entity_id(entity.entity_id)[1] else: - if device and entity.has_entity_name: # type: ignore[unreachable] + if device and entity.has_entity_name: device_name = device.name_by_user or device.name if not entity.name: suggested_object_id = device_name From bd7e943efe7b85e7b1208867a2027d605daa0c27 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Fri, 12 May 2023 09:18:34 -0400 Subject: [PATCH 328/822] Move Netgear LTE services to their own file (#92945) * Move Netgear LTE services to their own file * Update homeassistant/components/netgear_lte/services.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .../components/netgear_lte/__init__.py | 69 +-------------- .../components/netgear_lte/services.py | 86 +++++++++++++++++++ 2 files changed, 89 insertions(+), 66 deletions(-) create mode 100644 homeassistant/components/netgear_lte/services.py diff --git a/homeassistant/components/netgear_lte/__init__.py b/homeassistant/components/netgear_lte/__init__.py index 14aee2296173..ed9ee49a0de8 100644 --- a/homeassistant/components/netgear_lte/__init__.py +++ b/homeassistant/components/netgear_lte/__init__.py @@ -16,7 +16,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, Platform, ) -from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -25,31 +25,23 @@ from homeassistant.helpers.typing import ConfigType from . import sensor_types from .const import ( - ATTR_AUTOCONNECT, - ATTR_FAILOVER, ATTR_FROM, ATTR_HOST, ATTR_MESSAGE, ATTR_SMS_ID, - AUTOCONNECT_MODES, CONF_BINARY_SENSOR, CONF_NOTIFY, CONF_SENSOR, DISPATCHER_NETGEAR_LTE, DOMAIN, - FAILOVER_MODES, LOGGER, ) +from .services import async_setup_services SCAN_INTERVAL = timedelta(seconds=10) EVENT_SMS = "netgear_lte_sms" -SERVICE_DELETE_SMS = "delete_sms" -SERVICE_SET_OPTION = "set_option" -SERVICE_CONNECT_LTE = "connect_lte" -SERVICE_DISCONNECT_LTE = "disconnect_lte" - NOTIFY_SCHEMA = vol.Schema( { @@ -98,28 +90,6 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) -DELETE_SMS_SCHEMA = vol.Schema( - { - vol.Optional(ATTR_HOST): cv.string, - vol.Required(ATTR_SMS_ID): vol.All(cv.ensure_list, [cv.positive_int]), - } -) - -SET_OPTION_SCHEMA = vol.Schema( - vol.All( - cv.has_at_least_one_key(ATTR_FAILOVER, ATTR_AUTOCONNECT), - { - vol.Optional(ATTR_HOST): cv.string, - vol.Optional(ATTR_FAILOVER): vol.In(FAILOVER_MODES), - vol.Optional(ATTR_AUTOCONNECT): vol.In(AUTOCONNECT_MODES), - }, - ) -) - -CONNECT_LTE_SCHEMA = vol.Schema({vol.Optional(ATTR_HOST): cv.string}) - -DISCONNECT_LTE_SCHEMA = vol.Schema({vol.Optional(ATTR_HOST): cv.string}) - @attr.s class ModemData: @@ -173,40 +143,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) hass.data[DOMAIN] = LTEData(websession) - async def service_handler(service: ServiceCall) -> None: - """Apply a service.""" - host = service.data.get(ATTR_HOST) - conf = {CONF_HOST: host} - modem_data = hass.data[DOMAIN].get_modem_data(conf) - - if not modem_data: - LOGGER.error("%s: host %s unavailable", service.service, host) - return - - if service.service == SERVICE_DELETE_SMS: - for sms_id in service.data[ATTR_SMS_ID]: - await modem_data.modem.delete_sms(sms_id) - elif service.service == SERVICE_SET_OPTION: - if failover := service.data.get(ATTR_FAILOVER): - await modem_data.modem.set_failover_mode(failover) - if autoconnect := service.data.get(ATTR_AUTOCONNECT): - await modem_data.modem.set_autoconnect_mode(autoconnect) - elif service.service == SERVICE_CONNECT_LTE: - await modem_data.modem.connect_lte() - elif service.service == SERVICE_DISCONNECT_LTE: - await modem_data.modem.disconnect_lte() - - service_schemas = { - SERVICE_DELETE_SMS: DELETE_SMS_SCHEMA, - SERVICE_SET_OPTION: SET_OPTION_SCHEMA, - SERVICE_CONNECT_LTE: CONNECT_LTE_SCHEMA, - SERVICE_DISCONNECT_LTE: DISCONNECT_LTE_SCHEMA, - } - - for service, schema in service_schemas.items(): - hass.services.async_register( - DOMAIN, service, service_handler, schema=schema - ) + await async_setup_services(hass) netgear_lte_config = config[DOMAIN] diff --git a/homeassistant/components/netgear_lte/services.py b/homeassistant/components/netgear_lte/services.py new file mode 100644 index 000000000000..2ea98896791b --- /dev/null +++ b/homeassistant/components/netgear_lte/services.py @@ -0,0 +1,86 @@ +"""Services for the Netgear LTE integration.""" +from typing import TYPE_CHECKING + +import voluptuous as vol + +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import config_validation as cv + +from .const import ( + ATTR_AUTOCONNECT, + ATTR_FAILOVER, + ATTR_HOST, + ATTR_SMS_ID, + AUTOCONNECT_MODES, + DOMAIN, + FAILOVER_MODES, + LOGGER, +) + +if TYPE_CHECKING: + from . import LTEData, ModemData + +SERVICE_DELETE_SMS = "delete_sms" +SERVICE_SET_OPTION = "set_option" +SERVICE_CONNECT_LTE = "connect_lte" +SERVICE_DISCONNECT_LTE = "disconnect_lte" + +DELETE_SMS_SCHEMA = vol.Schema( + { + vol.Optional(ATTR_HOST): cv.string, + vol.Required(ATTR_SMS_ID): vol.All(cv.ensure_list, [cv.positive_int]), + } +) + +SET_OPTION_SCHEMA = vol.Schema( + vol.All( + cv.has_at_least_one_key(ATTR_FAILOVER, ATTR_AUTOCONNECT), + { + vol.Optional(ATTR_HOST): cv.string, + vol.Optional(ATTR_FAILOVER): vol.In(FAILOVER_MODES), + vol.Optional(ATTR_AUTOCONNECT): vol.In(AUTOCONNECT_MODES), + }, + ) +) + +CONNECT_LTE_SCHEMA = vol.Schema({vol.Optional(ATTR_HOST): cv.string}) + +DISCONNECT_LTE_SCHEMA = vol.Schema({vol.Optional(ATTR_HOST): cv.string}) + + +async def async_setup_services(hass: HomeAssistant) -> None: + """Set up services for Netgear LTE integration.""" + + async def service_handler(call: ServiceCall) -> None: + """Apply a service.""" + host = call.data.get(ATTR_HOST) + data: LTEData = hass.data[DOMAIN] + modem_data: ModemData = data.get_modem_data({CONF_HOST: host}) + + if not modem_data: + LOGGER.error("%s: host %s unavailable", call.service, host) + return + + if call.service == SERVICE_DELETE_SMS: + for sms_id in call.data[ATTR_SMS_ID]: + await modem_data.modem.delete_sms(sms_id) + elif call.service == SERVICE_SET_OPTION: + if failover := call.data.get(ATTR_FAILOVER): + await modem_data.modem.set_failover_mode(failover) + if autoconnect := call.data.get(ATTR_AUTOCONNECT): + await modem_data.modem.set_autoconnect_mode(autoconnect) + elif call.service == SERVICE_CONNECT_LTE: + await modem_data.modem.connect_lte() + elif call.service == SERVICE_DISCONNECT_LTE: + await modem_data.modem.disconnect_lte() + + service_schemas = { + SERVICE_DELETE_SMS: DELETE_SMS_SCHEMA, + SERVICE_SET_OPTION: SET_OPTION_SCHEMA, + SERVICE_CONNECT_LTE: CONNECT_LTE_SCHEMA, + SERVICE_DISCONNECT_LTE: DISCONNECT_LTE_SCHEMA, + } + + for service, schema in service_schemas.items(): + hass.services.async_register(DOMAIN, service, service_handler, schema=schema) From a05c20a498f7812e336e40f9e58a796265a45c48 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 12 May 2023 15:23:05 +0200 Subject: [PATCH 329/822] Filter replaying unrelated retained MQTT messages when subscribing to share topics (#88826) * Do not replay already processed retained subscr. * Add tests * Always replay wildcards * Update tests for debouncer * Rework for retained topics * Fix test * Correct comment * Add cleanup and test * Fix key error * Correct helper * Rename mock * Add comment on function _retained_init * Always replay initial retained payload * Apply suggestion moving msg.retain to outer check * Improve test on edge case * Improve comment formatting * Follow up comment - improve comments on test * Update homeassistant/components/mqtt/client.py Co-authored-by: Erik Montnemery --------- Co-authored-by: Erik Montnemery --- homeassistant/components/mqtt/client.py | 20 +- tests/components/mqtt/test_init.py | 319 +++++++++++++++++++++++- 2 files changed, 328 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index 3bd08403b786..e8eabe887f21 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -376,6 +376,11 @@ class MQTT: self._simple_subscriptions: dict[str, list[Subscription]] = {} self._wildcard_subscriptions: list[Subscription] = [] + # _retained_topics prevents a Subscription from receiving a + # retained message more than once per topic. This prevents flooding + # already active subscribers when new subscribers subscribe to a topic + # which has subscribed messages. + self._retained_topics: dict[Subscription, set[str]] = {} self.connected = False self._ha_started = asyncio.Event() self._cleanup_on_unload: list[Callable[[], None]] = [] @@ -618,6 +623,8 @@ class MQTT: """Remove subscription.""" self._async_untrack_subscription(subscription) self._matching_subscriptions.cache_clear() + if subscription in self._retained_topics: + del self._retained_topics[subscription] # Only unsubscribe if currently connected if self.connected: self._async_unsubscribe(topic) @@ -637,7 +644,7 @@ class MQTT: if topic in self._max_qos: del self._max_qos[topic] if topic in self._pending_subscriptions: - # avoid any pending subscription to be executed + # Avoid any pending subscription to be executed del self._pending_subscriptions[topic] self._pending_unsubscribes.add(topic) @@ -754,8 +761,9 @@ class MQTT: async def _async_resubscribe(self) -> None: """Resubscribe on reconnect.""" - # Group subscriptions to only re-subscribe once for each topic. self._max_qos.clear() + self._retained_topics.clear() + # Group subscriptions to only re-subscribe once for each topic. keyfunc = attrgetter("topic") self._async_queue_subscriptions( [ @@ -799,6 +807,14 @@ class MQTT: subscriptions = self._matching_subscriptions(msg.topic) for subscription in subscriptions: + if msg.retain: + retained_topics = self._retained_topics.setdefault(subscription, set()) + # Skip if the subscription already received a retained message + if msg.topic in retained_topics: + continue + # Remember the subscription had an initial retained message + self._retained_topics[subscription].add(msg.topic) + payload: SubscribePayloadType = msg.payload if subscription.encoding is not None: try: diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 5494b24c3986..fa8d2e42dda4 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -114,6 +114,26 @@ def record_calls(calls: list[ReceiveMessage]) -> MessageCallbackType: return record_calls +def help_assert_message( + msg: ReceiveMessage, + topic: str | None = None, + payload: str | None = None, + qos: int | None = None, + retain: bool | None = None, +) -> bool: + """Return True if all of the given attributes match with the message.""" + match: bool = True + if topic is not None: + match &= msg.topic == topic + if payload is not None: + match &= msg.payload == payload + if qos is not None: + match &= msg.qos == qos + if retain is not None: + match &= msg.retain == retain + return match + + async def test_mqtt_connects_on_home_assistant_mqtt_setup( hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient, @@ -1272,9 +1292,8 @@ async def test_subscribe_same_topic( calls_b.append(msg) await mqtt.async_subscribe(hass, "test/state", _callback_a, qos=0) - async_fire_mqtt_message( - hass, "test/state", "online" - ) # Simulate a (retained) message replaying + # Simulate a non retained message after the first subscription + async_fire_mqtt_message(hass, "test/state", "online", qos=0, retain=False) async_fire_time_changed(hass, utcnow() + timedelta(seconds=1)) await hass.async_block_till_done() assert len(calls_a) == 1 @@ -1285,18 +1304,271 @@ async def test_subscribe_same_topic( async_fire_time_changed(hass, utcnow() + timedelta(seconds=3)) await hass.async_block_till_done() await mqtt.async_subscribe(hass, "test/state", _callback_b, qos=1) - async_fire_mqtt_message( - hass, "test/state", "online" - ) # Simulate a (retained) message replaying + # Simulate an other non retained message after the second subscription + async_fire_mqtt_message(hass, "test/state", "online", qos=0, retain=False) async_fire_time_changed(hass, utcnow() + timedelta(seconds=1)) await hass.async_block_till_done() async_fire_time_changed(hass, utcnow() + timedelta(seconds=1)) await hass.async_block_till_done() + # Both subscriptions should receive updates assert len(calls_a) == 1 assert len(calls_b) == 1 mqtt_client_mock.subscribe.assert_called() +@patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.SUBSCRIBE_COOLDOWN", 0.0) +async def test_replaying_payload_same_topic( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test replaying retained messages. + + When subscribing to the same topic again, SUBSCRIBE must be sent to the broker again + for it to resend any retained messages for new subscriptions. + Retained messages must only be replayed for new subscriptions, except + when the MQTT client is reconnecting. + """ + mqtt_mock = await mqtt_mock_entry() + + # Fake that the client is connected + mqtt_mock().connected = True + + calls_a: list[ReceiveMessage] = [] + calls_b: list[ReceiveMessage] = [] + + def _callback_a(msg: ReceiveMessage) -> None: + calls_a.append(msg) + + def _callback_b(msg: ReceiveMessage) -> None: + calls_b.append(msg) + + await mqtt.async_subscribe(hass, "test/state", _callback_a) + async_fire_mqtt_message( + hass, "test/state", "online", qos=0, retain=True + ) # Simulate a (retained) message played back + await hass.async_block_till_done() + assert len(calls_a) == 1 + mqtt_client_mock.subscribe.assert_called() + calls_a = [] + mqtt_client_mock.reset_mock() + + await mqtt.async_subscribe(hass, "test/state", _callback_b) + + # Simulate edge case where non retained message was received + # after subscription at HA but before the debouncer delay was passed. + # The message without retain flag directly after a subscription should + # be processed by both subscriptions. + async_fire_mqtt_message(hass, "test/state", "online", qos=0, retain=False) + + # Simulate a (retained) message played back on new subscriptions + async_fire_mqtt_message(hass, "test/state", "online", qos=0, retain=True) + + # Make sure the debouncer delay was passed + await hass.async_block_till_done() + async_fire_time_changed(hass, utcnow() + timedelta(seconds=3)) + await hass.async_block_till_done() + + # The current subscription only received the message without retain flag + assert len(calls_a) == 1 + assert help_assert_message(calls_a[0], "test/state", "online", qos=0, retain=False) + # The retained message playback should only be processed by the new subscription. + # The existing subscription already got the latest update, hence the existing + # subscription should not receive the replayed (retained) message. + # Messages without retain flag are received on both subscriptions. + assert len(calls_b) == 2 + assert help_assert_message(calls_b[0], "test/state", "online", qos=0, retain=False) + assert help_assert_message(calls_b[1], "test/state", "online", qos=0, retain=True) + mqtt_client_mock.subscribe.assert_called() + + calls_a = [] + calls_b = [] + mqtt_client_mock.reset_mock() + + # Simulate new message played back on new subscriptions + # After connecting the retain flag will not be set, even if the + # payload published was retained, we cannot see that + async_fire_mqtt_message(hass, "test/state", "online", qos=0, retain=False) + await hass.async_block_till_done() + async_fire_time_changed(hass, utcnow() + timedelta(seconds=3)) + await hass.async_block_till_done() + assert len(calls_a) == 1 + assert help_assert_message(calls_a[0], "test/state", "online", qos=0, retain=False) + assert len(calls_b) == 1 + assert help_assert_message(calls_b[0], "test/state", "online", qos=0, retain=False) + + # Now simulate the broker was disconnected shortly + calls_a = [] + calls_b = [] + mqtt_client_mock.reset_mock() + mqtt_client_mock.on_disconnect(None, None, 0) + mqtt_client_mock.on_connect(None, None, None, 0) + await hass.async_block_till_done() + mqtt_client_mock.subscribe.assert_called() + # Simulate a (retained) message played back after reconnecting + async_fire_mqtt_message(hass, "test/state", "online", qos=0, retain=True) + await hass.async_block_till_done() + async_fire_time_changed(hass, utcnow() + timedelta(seconds=3)) + await hass.async_block_till_done() + # Both subscriptions now should replay the retained message + assert len(calls_a) == 1 + assert help_assert_message(calls_a[0], "test/state", "online", qos=0, retain=True) + assert len(calls_b) == 1 + assert help_assert_message(calls_b[0], "test/state", "online", qos=0, retain=True) + + +@patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.SUBSCRIBE_COOLDOWN", 0.0) +async def test_replaying_payload_after_resubscribing( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test replaying and filtering retained messages after resubscribing. + + When subscribing to the same topic again, SUBSCRIBE must be sent to the broker again + for it to resend any retained messages for new subscriptions. + Retained messages must only be replayed for new subscriptions, except + when the MQTT client is reconnection. + """ + mqtt_mock = await mqtt_mock_entry() + + # Fake that the client is connected + mqtt_mock().connected = True + + calls_a: list[ReceiveMessage] = [] + + def _callback_a(msg: ReceiveMessage) -> None: + calls_a.append(msg) + + unsub = await mqtt.async_subscribe(hass, "test/state", _callback_a) + await hass.async_block_till_done() + async_fire_time_changed(hass, utcnow() + timedelta(seconds=3)) + await hass.async_block_till_done() + mqtt_client_mock.subscribe.assert_called() + + # Simulate a (retained) message played back + async_fire_mqtt_message(hass, "test/state", "online", qos=0, retain=True) + await hass.async_block_till_done() + assert help_assert_message(calls_a[0], "test/state", "online", qos=0, retain=True) + calls_a.clear() + + # Test we get updates + async_fire_mqtt_message(hass, "test/state", "offline", qos=0, retain=False) + await hass.async_block_till_done() + assert help_assert_message(calls_a[0], "test/state", "offline", qos=0, retain=False) + calls_a.clear() + + # Test we filter new retained updates + async_fire_mqtt_message(hass, "test/state", "offline", qos=0, retain=True) + await hass.async_block_till_done() + assert len(calls_a) == 0 + + # Unsubscribe an resubscribe again + unsub() + unsub = await mqtt.async_subscribe(hass, "test/state", _callback_a) + await hass.async_block_till_done() + async_fire_time_changed(hass, utcnow() + timedelta(seconds=3)) + await hass.async_block_till_done() + mqtt_client_mock.subscribe.assert_called() + + # Simulate we can receive a (retained) played back message again + async_fire_mqtt_message(hass, "test/state", "online", qos=0, retain=True) + await hass.async_block_till_done() + assert help_assert_message(calls_a[0], "test/state", "online", qos=0, retain=True) + + +@patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.SUBSCRIBE_COOLDOWN", 0.0) +async def test_replaying_payload_wildcard_topic( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test replaying retained messages. + + When we have multiple subscriptions to the same wildcard topic, + SUBSCRIBE must be sent to the broker again + for it to resend any retained messages for new subscriptions. + Retained messages should only be replayed for new subscriptions, except + when the MQTT client is reconnection. + """ + mqtt_mock = await mqtt_mock_entry() + + # Fake that the client is connected + mqtt_mock().connected = True + + calls_a: list[ReceiveMessage] = [] + calls_b: list[ReceiveMessage] = [] + + def _callback_a(msg: ReceiveMessage) -> None: + calls_a.append(msg) + + def _callback_b(msg: ReceiveMessage) -> None: + calls_b.append(msg) + + await mqtt.async_subscribe(hass, "test/#", _callback_a) + # Simulate (retained) messages being played back on new subscriptions + async_fire_mqtt_message(hass, "test/state1", "new_value_1", qos=0, retain=True) + async_fire_mqtt_message(hass, "test/state2", "new_value_2", qos=0, retain=True) + await hass.async_block_till_done() + async_fire_time_changed(hass, utcnow() + timedelta(seconds=3)) # cooldown + await hass.async_block_till_done() + assert len(calls_a) == 2 + mqtt_client_mock.subscribe.assert_called() + calls_a = [] + mqtt_client_mock.reset_mock() + + # resubscribe to the wild card topic again + await mqtt.async_subscribe(hass, "test/#", _callback_b) + # Simulate (retained) messages being played back on new subscriptions + async_fire_mqtt_message(hass, "test/state1", "initial_value_1", qos=0, retain=True) + async_fire_mqtt_message(hass, "test/state2", "initial_value_2", qos=0, retain=True) + await hass.async_block_till_done() + async_fire_time_changed(hass, utcnow() + timedelta(seconds=3)) # cooldown + await hass.async_block_till_done() + # The retained messages playback should only be processed for the new subscriptions + assert len(calls_a) == 0 + assert len(calls_b) == 2 + mqtt_client_mock.subscribe.assert_called() + + calls_a = [] + calls_b = [] + mqtt_client_mock.reset_mock() + + # Simulate new messages being received + async_fire_mqtt_message(hass, "test/state1", "update_value_1", qos=0, retain=False) + async_fire_mqtt_message(hass, "test/state2", "update_value_2", qos=0, retain=False) + await hass.async_block_till_done() + assert len(calls_a) == 2 + assert len(calls_b) == 2 + + # Now simulate the broker was disconnected shortly + calls_a = [] + calls_b = [] + mqtt_client_mock.reset_mock() + mqtt_client_mock.on_disconnect(None, None, 0) + mqtt_client_mock.on_connect(None, None, None, 0) + await hass.async_block_till_done() + async_fire_time_changed(hass, utcnow() + timedelta(seconds=3)) # cooldown + await hass.async_block_till_done() + mqtt_client_mock.subscribe.assert_called() + # Simulate the (retained) messages are played back after reconnecting + # for all subscriptions + async_fire_mqtt_message(hass, "test/state1", "update_value_1", qos=0, retain=True) + async_fire_mqtt_message(hass, "test/state2", "update_value_2", qos=0, retain=True) + await hass.async_block_till_done() + async_fire_time_changed(hass, utcnow() + timedelta(seconds=3)) # cooldown + await hass.async_block_till_done() + # Both subscriptions should replay + assert len(calls_a) == 2 + assert len(calls_b) == 2 + + @patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 0.0) @patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 0.0) @patch("homeassistant.components.mqtt.client.SUBSCRIBE_COOLDOWN", 0.0) @@ -3032,33 +3304,62 @@ async def test_debug_info_qos_retain( start_dt = datetime(2019, 1, 1, 0, 0, 0) with patch("homeassistant.util.dt.utcnow") as dt_utcnow: dt_utcnow.return_value = start_dt + # simulate the first message was replayed from the broker with retained flag + async_fire_mqtt_message(hass, "sensor/abc", "123", qos=0, retain=True) + # simulate an update message async_fire_mqtt_message(hass, "sensor/abc", "123", qos=0, retain=False) + # simpulate someone else subscribed and retained messages were replayed async_fire_mqtt_message(hass, "sensor/abc", "123", qos=1, retain=True) + # simulate an update message + async_fire_mqtt_message(hass, "sensor/abc", "123", qos=1, retain=False) + # simulate an update message async_fire_mqtt_message(hass, "sensor/abc", "123", qos=2, retain=False) debug_info_data = debug_info.info_for_device(hass, device.id) assert len(debug_info_data["entities"][0]["subscriptions"]) == 1 + # The replayed retained payload was processed + messages = debug_info_data["entities"][0]["subscriptions"][0]["messages"] + assert { + "payload": "123", + "qos": 0, + "retain": True, + "time": start_dt, + "topic": "sensor/abc", + } in messages + # The not retained update was processed normally assert { "payload": "123", "qos": 0, "retain": False, "time": start_dt, "topic": "sensor/abc", - } in debug_info_data["entities"][0]["subscriptions"][0]["messages"] + } in messages + # Since the MQTT client has not lost the connection and has not resubscribed + # The retained payload is not replayed and filtered out as it already + # received a value and appears to be received on an existing subscription assert { "payload": "123", "qos": 1, "retain": True, "time": start_dt, "topic": "sensor/abc", - } in debug_info_data["entities"][0]["subscriptions"][0]["messages"] + } not in messages + # The not retained update was processed normally + assert { + "payload": "123", + "qos": 1, + "retain": False, + "time": start_dt, + "topic": "sensor/abc", + } in messages + # The not retained update was processed normally assert { "payload": "123", "qos": 2, "retain": False, "time": start_dt, "topic": "sensor/abc", - } in debug_info_data["entities"][0]["subscriptions"][0]["messages"] + } in messages async def test_publish_json_from_template( From 45e7283584901567a110a39f9ba5198ad57b9c30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Fri, 12 May 2023 15:56:17 +0200 Subject: [PATCH 330/822] Add Airzone fan speed support (#92840) --- homeassistant/components/airzone/climate.py | 62 +++++++++++++++++ tests/components/airzone/test_climate.py | 77 +++++++++++++++++++++ tests/components/airzone/util.py | 6 ++ 3 files changed, 145 insertions(+) diff --git a/homeassistant/components/airzone/climate.py b/homeassistant/components/airzone/climate.py index 1a167f49f78e..764beddd4336 100644 --- a/homeassistant/components/airzone/climate.py +++ b/homeassistant/components/airzone/climate.py @@ -8,6 +8,7 @@ from aioairzone.const import ( API_MODE, API_ON, API_SET_POINT, + API_SPEED, AZD_ACTION, AZD_HUMIDITY, AZD_MASTER, @@ -15,6 +16,8 @@ from aioairzone.const import ( AZD_MODES, AZD_NAME, AZD_ON, + AZD_SPEED, + AZD_SPEEDS, AZD_TEMP, AZD_TEMP_MAX, AZD_TEMP_MIN, @@ -24,6 +27,10 @@ from aioairzone.const import ( ) from homeassistant.components.climate import ( + FAN_AUTO, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, ClimateEntity, ClimateEntityFeature, HVACAction, @@ -39,6 +46,22 @@ from .const import API_TEMPERATURE_STEP, DOMAIN, TEMP_UNIT_LIB_TO_HASS from .coordinator import AirzoneUpdateCoordinator from .entity import AirzoneZoneEntity +BASE_FAN_SPEEDS: Final[dict[int, str]] = { + 0: FAN_AUTO, + 1: FAN_LOW, +} +FAN_SPEED_MAPS: Final[dict[int, dict[int, str]]] = { + 2: BASE_FAN_SPEEDS + | { + 2: FAN_HIGH, + }, + 3: BASE_FAN_SPEEDS + | { + 2: FAN_MEDIUM, + 3: FAN_HIGH, + }, +} + HVAC_ACTION_LIB_TO_HASS: Final[dict[OperationAction, HVACAction]] = { OperationAction.COOLING: HVACAction.COOLING, OperationAction.DRYING: HVACAction.DRYING, @@ -84,6 +107,9 @@ async def async_setup_entry( class AirzoneClimate(AirzoneZoneEntity, ClimateEntity): """Define an Airzone sensor.""" + _speeds: dict[int, str] = {} + _speeds_reverse: dict[str, int] = {} + def __init__( self, coordinator: AirzoneUpdateCoordinator, @@ -106,8 +132,35 @@ class AirzoneClimate(AirzoneZoneEntity, ClimateEntity): self._attr_hvac_modes = [ HVAC_MODE_LIB_TO_HASS[mode] for mode in self.get_airzone_value(AZD_MODES) ] + if ( + self.get_airzone_value(AZD_SPEED) is not None + and self.get_airzone_value(AZD_SPEEDS) is not None + ): + self._set_fan_speeds() + self._async_update_attrs() + def _set_fan_speeds(self) -> None: + self._attr_supported_features |= ClimateEntityFeature.FAN_MODE + + speeds = self.get_airzone_value(AZD_SPEEDS) + max_speed = max(speeds) + if _speeds := FAN_SPEED_MAPS.get(max_speed): + self._speeds = _speeds + else: + for speed in speeds: + if speed == 0: + self._speeds[speed] = FAN_AUTO + else: + self._speeds[speed] = f"{int(round((speed * 100) / max_speed, 0))}%" + + self._speeds[1] = FAN_LOW + self._speeds[int(round((max_speed + 1) / 2, 0))] = FAN_MEDIUM + self._speeds[max_speed] = FAN_HIGH + + self._speeds_reverse = {v: k for k, v in self._speeds.items()} + self._attr_fan_modes = list(self._speeds_reverse) + async def async_turn_on(self) -> None: """Turn the entity on.""" params = { @@ -122,6 +175,13 @@ class AirzoneClimate(AirzoneZoneEntity, ClimateEntity): } await self._async_update_hvac_params(params) + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set fan mode.""" + params = { + API_SPEED: self._speeds_reverse.get(fan_mode), + } + await self._async_update_hvac_params(params) + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set hvac mode.""" params = {} @@ -167,3 +227,5 @@ class AirzoneClimate(AirzoneZoneEntity, ClimateEntity): else: self._attr_hvac_mode = HVACMode.OFF self._attr_target_temperature = self.get_airzone_value(AZD_TEMP_SET) + if self.supported_features & ClimateEntityFeature.FAN_MODE: + self._attr_fan_mode = self._speeds.get(self.get_airzone_value(AZD_SPEED)) diff --git a/tests/components/airzone/test_climate.py b/tests/components/airzone/test_climate.py index caf8cfe13bd8..cfbf6049e7c6 100644 --- a/tests/components/airzone/test_climate.py +++ b/tests/components/airzone/test_climate.py @@ -7,6 +7,7 @@ from aioairzone.const import ( API_MODE, API_ON, API_SET_POINT, + API_SPEED, API_SYSTEM_ID, API_ZONE_ID, ) @@ -17,6 +18,8 @@ from homeassistant.components.airzone.const import API_TEMPERATURE_STEP from homeassistant.components.climate import ( ATTR_CURRENT_HUMIDITY, ATTR_CURRENT_TEMPERATURE, + ATTR_FAN_MODE, + ATTR_FAN_MODES, ATTR_HVAC_ACTION, ATTR_HVAC_MODE, ATTR_HVAC_MODES, @@ -24,6 +27,11 @@ from homeassistant.components.climate import ( ATTR_MIN_TEMP, ATTR_TARGET_TEMP_STEP, DOMAIN as CLIMATE_DOMAIN, + FAN_AUTO, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + SERVICE_SET_FAN_MODE, SERVICE_SET_HVAC_MODE, SERVICE_SET_TEMPERATURE, HVACAction, @@ -50,6 +58,8 @@ async def test_airzone_create_climates(hass: HomeAssistant) -> None: assert state.state == HVACMode.OFF assert state.attributes.get(ATTR_CURRENT_HUMIDITY) == 36 assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == 21.2 + assert state.attributes.get(ATTR_FAN_MODE) is None + assert state.attributes.get(ATTR_FAN_MODES) is None assert state.attributes.get(ATTR_HVAC_ACTION) == HVACAction.OFF assert state.attributes.get(ATTR_HVAC_MODES) == [ HVACMode.OFF, @@ -67,6 +77,8 @@ async def test_airzone_create_climates(hass: HomeAssistant) -> None: assert state.state == HVACMode.HEAT assert state.attributes.get(ATTR_CURRENT_HUMIDITY) == 35 assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == 20.8 + assert state.attributes.get(ATTR_FAN_MODE) is None + assert state.attributes.get(ATTR_FAN_MODES) is None assert state.attributes.get(ATTR_HVAC_ACTION) == HVACAction.IDLE assert state.attributes.get(ATTR_HVAC_MODES) == [ HVACMode.OFF, @@ -84,6 +96,8 @@ async def test_airzone_create_climates(hass: HomeAssistant) -> None: assert state.state == HVACMode.OFF assert state.attributes.get(ATTR_CURRENT_HUMIDITY) == 40 assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == 20.5 + assert state.attributes.get(ATTR_FAN_MODE) is None + assert state.attributes.get(ATTR_FAN_MODES) is None assert state.attributes.get(ATTR_HVAC_ACTION) == HVACAction.OFF assert state.attributes.get(ATTR_HVAC_MODES) == [ HVACMode.OFF, @@ -101,6 +115,12 @@ async def test_airzone_create_climates(hass: HomeAssistant) -> None: assert state.state == HVACMode.HEAT assert state.attributes.get(ATTR_CURRENT_HUMIDITY) == 39 assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == 21.1 + assert state.attributes.get(ATTR_FAN_MODE) == FAN_AUTO + assert state.attributes.get(ATTR_FAN_MODES) == [ + FAN_AUTO, + FAN_LOW, + FAN_HIGH, + ] assert state.attributes.get(ATTR_HVAC_ACTION) == HVACAction.HEATING assert state.attributes.get(ATTR_HVAC_MODES) == [ HVACMode.OFF, @@ -118,6 +138,13 @@ async def test_airzone_create_climates(hass: HomeAssistant) -> None: assert state.state == HVACMode.OFF assert state.attributes.get(ATTR_CURRENT_HUMIDITY) == 34 assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == 19.6 + assert state.attributes.get(ATTR_FAN_MODE) == FAN_AUTO + assert state.attributes.get(ATTR_FAN_MODES) == [ + FAN_AUTO, + FAN_LOW, + FAN_MEDIUM, + FAN_HIGH, + ] assert state.attributes.get(ATTR_HVAC_ACTION) == HVACAction.OFF assert state.attributes.get(ATTR_HVAC_MODES) == [ HVACMode.OFF, @@ -135,6 +162,14 @@ async def test_airzone_create_climates(hass: HomeAssistant) -> None: assert state.state == HVACMode.OFF assert state.attributes.get(ATTR_CURRENT_HUMIDITY) == 62 assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == 22.3 + assert state.attributes.get(ATTR_FAN_MODE) == FAN_AUTO + assert state.attributes.get(ATTR_FAN_MODES) == [ + FAN_AUTO, + FAN_LOW, + FAN_MEDIUM, + "75%", + FAN_HIGH, + ] assert state.attributes.get(ATTR_HVAC_ACTION) == HVACAction.OFF assert state.attributes.get(ATTR_HVAC_MODES) == [ HVACMode.HEAT_COOL, @@ -149,6 +184,15 @@ async def test_airzone_create_climates(hass: HomeAssistant) -> None: assert state.state == HVACMode.HEAT_COOL assert state.attributes.get(ATTR_CURRENT_HUMIDITY) is None assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == 21.7 + assert state.attributes.get(ATTR_FAN_MODE) == "40%" + assert state.attributes.get(ATTR_FAN_MODES) == [ + FAN_AUTO, + FAN_LOW, + "40%", + FAN_MEDIUM, + "80%", + FAN_HIGH, + ] assert state.attributes.get(ATTR_HVAC_ACTION) == HVACAction.COOLING assert state.attributes.get(ATTR_HVAC_MODES) == [ HVACMode.FAN_ONLY, @@ -363,6 +407,39 @@ async def test_airzone_climate_set_hvac_slave_error(hass: HomeAssistant) -> None assert state.state == HVACMode.OFF +async def test_airzone_climate_set_fan_mode(hass: HomeAssistant) -> None: + """Test setting the target temperature.""" + + HVAC_MOCK = { + API_DATA: [ + { + API_SYSTEM_ID: 1, + API_ZONE_ID: 1, + API_SPEED: 2, + } + ] + } + + await async_init_integration(hass) + + with patch( + "homeassistant.components.airzone.AirzoneLocalApi.put_hvac", + return_value=HVAC_MOCK, + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + { + ATTR_ENTITY_ID: "climate.salon", + ATTR_FAN_MODE: FAN_MEDIUM, + }, + blocking=True, + ) + + state = hass.states.get("climate.salon") + assert state.attributes.get(ATTR_FAN_MODE) == FAN_MEDIUM + + async def test_airzone_climate_set_temp(hass: HomeAssistant) -> None: """Test setting the target temperature.""" diff --git a/tests/components/airzone/util.py b/tests/components/airzone/util.py index bbbe00a431b6..1f7bbd5bd88f 100644 --- a/tests/components/airzone/util.py +++ b/tests/components/airzone/util.py @@ -93,6 +93,8 @@ HVAC_MOCK = { API_FLOOR_DEMAND: 0, API_HEAT_ANGLE: 0, API_COLD_ANGLE: 0, + API_SPEED: 0, + API_SPEEDS: 3, }, { API_SYSTEM_ID: 1, @@ -119,6 +121,8 @@ HVAC_MOCK = { API_FLOOR_DEMAND: 1, API_HEAT_ANGLE: 1, API_COLD_ANGLE: 2, + API_SPEED: 0, + API_SPEEDS: 2, }, { API_SYSTEM_ID: 1, @@ -221,6 +225,8 @@ HVAC_MOCK = { API_HUMIDITY: 62, API_UNITS: 0, API_ERRORS: [], + API_SPEED: 0, + API_SPEEDS: 4, }, ] }, From e883fef8ab31ccdc423950de372618e57e3d8434 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 12 May 2023 16:02:42 +0200 Subject: [PATCH 331/822] Fix remove of device when surveillance station is not used in Synology DSM (#92957) --- homeassistant/components/synology_dsm/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/synology_dsm/__init__.py b/homeassistant/components/synology_dsm/__init__.py index c17a26794df0..ecda3addcb55 100644 --- a/homeassistant/components/synology_dsm/__init__.py +++ b/homeassistant/components/synology_dsm/__init__.py @@ -147,8 +147,10 @@ async def async_remove_config_entry_device( api = data.api serial = api.information.serial storage = api.storage - # get_all_cameras does not do I/O - all_cameras: list[SynoCamera] = api.surveillance_station.get_all_cameras() + all_cameras: list[SynoCamera] = [] + if api.surveillance_station is not None: + # get_all_cameras does not do I/O + all_cameras = api.surveillance_station.get_all_cameras() device_ids = chain( (camera.id for camera in all_cameras), storage.volumes_ids, From 4a50c3fbae8b2fafe51555c2ec237cf09e0b67ee Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Fri, 12 May 2023 16:04:36 +0200 Subject: [PATCH 332/822] Bump reolink-aio to 0.5.15 (#92979) --- homeassistant/components/reolink/button.py | 8 ++++---- homeassistant/components/reolink/manifest.json | 2 +- homeassistant/components/reolink/siren.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/reolink/button.py b/homeassistant/components/reolink/button.py index 65bb8036c0bc..3aa5faa527b0 100644 --- a/homeassistant/components/reolink/button.py +++ b/homeassistant/components/reolink/button.py @@ -46,28 +46,28 @@ BUTTON_ENTITIES = ( key="ptz_left", name="PTZ left", icon="mdi:pan", - supported=lambda api, ch: api.supported(ch, "pan_tilt"), + supported=lambda api, ch: api.supported(ch, "pan"), method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.left.value), ), ReolinkButtonEntityDescription( key="ptz_right", name="PTZ right", icon="mdi:pan", - supported=lambda api, ch: api.supported(ch, "pan_tilt"), + supported=lambda api, ch: api.supported(ch, "pan"), method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.right.value), ), ReolinkButtonEntityDescription( key="ptz_up", name="PTZ up", icon="mdi:pan", - supported=lambda api, ch: api.supported(ch, "pan_tilt"), + supported=lambda api, ch: api.supported(ch, "tilt"), method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.up.value), ), ReolinkButtonEntityDescription( key="ptz_down", name="PTZ down", icon="mdi:pan", - supported=lambda api, ch: api.supported(ch, "pan_tilt"), + supported=lambda api, ch: api.supported(ch, "tilt"), method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.down.value), ), ReolinkButtonEntityDescription( diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index cad89ac48c1b..6a4ae98a1546 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -18,5 +18,5 @@ "documentation": "https://www.home-assistant.io/integrations/reolink", "iot_class": "local_push", "loggers": ["reolink_aio"], - "requirements": ["reolink-aio==0.5.13"] + "requirements": ["reolink-aio==0.5.15"] } diff --git a/homeassistant/components/reolink/siren.py b/homeassistant/components/reolink/siren.py index 405c3e2716de..9dba3b840eab 100644 --- a/homeassistant/components/reolink/siren.py +++ b/homeassistant/components/reolink/siren.py @@ -35,7 +35,7 @@ SIREN_ENTITIES = ( key="siren", name="Siren", icon="mdi:alarm-light", - supported=lambda api, ch: api.supported(ch, "siren"), + supported=lambda api, ch: api.supported(ch, "siren_play"), ), ) diff --git a/requirements_all.txt b/requirements_all.txt index cacf2ec8d5cb..0ebb41873978 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2242,7 +2242,7 @@ regenmaschine==2022.11.0 renault-api==0.1.13 # homeassistant.components.reolink -reolink-aio==0.5.13 +reolink-aio==0.5.15 # homeassistant.components.python_script restrictedpython==6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 461473af6469..f93b1f5b6635 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1623,7 +1623,7 @@ regenmaschine==2022.11.0 renault-api==0.1.13 # homeassistant.components.reolink -reolink-aio==0.5.13 +reolink-aio==0.5.15 # homeassistant.components.python_script restrictedpython==6.0 From 7807df44265f285aa80fd2ca1a867425014c9b17 Mon Sep 17 00:00:00 2001 From: Matrix Date: Fri, 12 May 2023 22:15:04 +0800 Subject: [PATCH 333/822] Fix yolink dimmer Incorrect state update (#92986) fix state update --- homeassistant/components/yolink/light.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/yolink/light.py b/homeassistant/components/yolink/light.py index 7c11b54efa58..a7f52e801b2c 100644 --- a/homeassistant/components/yolink/light.py +++ b/homeassistant/components/yolink/light.py @@ -51,9 +51,9 @@ class YoLinkDimmerEntity(YoLinkEntity, LightEntity): @callback def update_entity_state(self, state: dict[str, Any]) -> None: """Update HA Entity State.""" - if (dimmer_is_on := state.get("state")) is not None: + if (dimmer_state := state.get("state")) is not None: # update _attr_is_on when device report it's state - self._attr_is_on = dimmer_is_on + self._attr_is_on = dimmer_state == "open" if (brightness := state.get("brightness")) is not None: self._attr_brightness = round(255 * brightness / 100) self.async_write_ha_state() From 9a70f470494534172a0d7a4972cc968b682228d1 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 12 May 2023 16:46:37 +0200 Subject: [PATCH 334/822] Teach UnitlessRatioConverter about ppm and ppb (#92989) --- homeassistant/components/sensor/const.py | 1 + homeassistant/util/unit_conversion.py | 4 ++++ tests/util/test_unit_conversion.py | 4 ++++ 3 files changed, 9 insertions(+) diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index 8ab90f03c014..17155912e482 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -466,6 +466,7 @@ UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] = SensorDeviceClass.PRESSURE: PressureConverter, SensorDeviceClass.SPEED: SpeedConverter, SensorDeviceClass.TEMPERATURE: TemperatureConverter, + SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS: UnitlessRatioConverter, SensorDeviceClass.VOLTAGE: ElectricPotentialConverter, SensorDeviceClass.VOLUME: VolumeConverter, SensorDeviceClass.VOLUME_STORAGE: VolumeConverter, diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index 0fde15acd71a..a22a204e69a6 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -2,6 +2,8 @@ from __future__ import annotations from homeassistant.const import ( + CONCENTRATION_PARTS_PER_BILLION, + CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, UNIT_NOT_RECOGNIZED_TEMPLATE, UnitOfDataRate, @@ -421,6 +423,8 @@ class UnitlessRatioConverter(BaseUnitConverter): NORMALIZED_UNIT = None _UNIT_CONVERSION: dict[str | None, float] = { None: 1, + CONCENTRATION_PARTS_PER_BILLION: 1000000000, + CONCENTRATION_PARTS_PER_MILLION: 1000000, PERCENTAGE: 100, } VALID_UNITS = { diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index 51b126bce3bb..1aea64201b53 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -6,6 +6,8 @@ import inspect import pytest from homeassistant.const import ( + CONCENTRATION_PARTS_PER_BILLION, + CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, UnitOfDataRate, UnitOfElectricCurrent, @@ -365,6 +367,8 @@ _CONVERTED_VALUE: dict[ ], UnitlessRatioConverter: [ (5, None, 500, PERCENTAGE), + (5, None, 5000000000, CONCENTRATION_PARTS_PER_BILLION), + (5, None, 5000000, CONCENTRATION_PARTS_PER_MILLION), (5, PERCENTAGE, 0.05, None), ], VolumeConverter: [ From 8711735ec0df6b062bd9687d597fa583085ac929 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 13 May 2023 00:13:57 +0900 Subject: [PATCH 335/822] Improve websocket throughput and reduce latency (#92967) --- .../components/websocket_api/commands.py | 2 +- .../components/websocket_api/connection.py | 6 ++ .../components/websocket_api/http.py | 77 +++++++++++------ tests/components/websocket_api/test_http.py | 82 ++++++++++++++----- 4 files changed, 120 insertions(+), 47 deletions(-) diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index 2b146d944724..dc6cc84c09c8 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -715,7 +715,7 @@ def handle_supported_features( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle setting supported features.""" - connection.supported_features = msg["features"] + connection.set_supported_features(msg["features"]) connection.send_result(msg["id"]) diff --git a/homeassistant/components/websocket_api/connection.py b/homeassistant/components/websocket_api/connection.py index f91cc3a827af..1f6fd3029689 100644 --- a/homeassistant/components/websocket_api/connection.py +++ b/homeassistant/components/websocket_api/connection.py @@ -48,6 +48,7 @@ class ActiveConnection: self.refresh_token_id = refresh_token.id self.subscriptions: dict[Hashable, Callable[[], Any]] = {} self.last_id = 0 + self.can_coalesce = False self.supported_features: dict[str, float] = {} self.handlers: dict[str, tuple[MessageHandler, vol.Schema]] = self.hass.data[ const.DOMAIN @@ -55,6 +56,11 @@ class ActiveConnection: self.binary_handlers: list[BinaryHandler | None] = [] current_connection.set(self) + def set_supported_features(self, features: dict[str, float]) -> None: + """Set supported features.""" + self.supported_features = features + self.can_coalesce = const.FEATURE_COALESCE_MESSAGES in features + def get_description(self, request: web.Request | None) -> str: """Return a description of the connection.""" description = self.user.name or "" diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py index 75eccc7aba99..7671d0ceaef5 100644 --- a/homeassistant/components/websocket_api/http.py +++ b/homeassistant/components/websocket_api/http.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +from collections import deque from collections.abc import Callable from contextlib import suppress import datetime as dt @@ -22,7 +23,6 @@ from .auth import AuthPhase, auth_required_message from .const import ( CANCELLATION_ERRORS, DATA_CONNECTIONS, - FEATURE_COALESCE_MESSAGES, MAX_PENDING_MSG, PENDING_MSG_PEAK, PENDING_MSG_PEAK_TIME, @@ -71,7 +71,6 @@ class WebSocketHandler: self.hass = hass self.request = request self.wsock = web.WebSocketResponse(heartbeat=55) - self._to_write: asyncio.Queue = asyncio.Queue(maxsize=MAX_PENDING_MSG) self._handle_task: asyncio.Task | None = None self._writer_task: asyncio.Task | None = None self._closing: bool = False @@ -79,6 +78,13 @@ class WebSocketHandler: self._peak_checker_unsub: Callable[[], None] | None = None self.connection: ActiveConnection | None = None + # The WebSocketHandler has a single consumer and path + # to where messages are queued. This allows the implementation + # to use a deque and an asyncio.Future to avoid the overhead of + # an asyncio.Queue. + self._message_queue: deque = deque() + self._ready_future: asyncio.Future[None] | None = None + @property def description(self) -> str: """Return a description of the connection.""" @@ -88,39 +94,52 @@ class WebSocketHandler: async def _writer(self) -> None: """Write outgoing messages.""" - # Exceptions if Socket disconnected or cancelled by connection handler - to_write = self._to_write + # Variables are set locally to avoid lookups in the loop + message_queue = self._message_queue logger = self._logger - wsock = self.wsock + send_str = self.wsock.send_str + loop = self.hass.loop + debug = logger.debug + # Exceptions if Socket disconnected or cancelled by connection handler try: with suppress(RuntimeError, ConnectionResetError, *CANCELLATION_ERRORS): while not self.wsock.closed: - if (process := await to_write.get()) is None: + if (messages_remaining := len(message_queue)) == 0: + self._ready_future = loop.create_future() + await self._ready_future + messages_remaining = len(message_queue) + + # A None message is used to signal the end of the connection + if (process := message_queue.popleft()) is None: return + + messages_remaining -= 1 message = process if isinstance(process, str) else process() + if ( - to_write.empty() + not messages_remaining or not self.connection - or FEATURE_COALESCE_MESSAGES - not in self.connection.supported_features + or not self.connection.can_coalesce ): - logger.debug("Sending %s", message) - await wsock.send_str(message) + debug("Sending %s", message) + await send_str(message) continue messages: list[str] = [message] - while not to_write.empty(): - if (process := to_write.get_nowait()) is None: + while messages_remaining: + # A None message is used to signal the end of the connection + if (process := message_queue.popleft()) is None: return messages.append( process if isinstance(process, str) else process() ) + messages_remaining -= 1 coalesced_messages = "[" + ",".join(messages) + "]" - logger.debug("Sending %s", coalesced_messages) - await wsock.send_str(coalesced_messages) + debug("Sending %s", coalesced_messages) + await send_str(coalesced_messages) finally: - # Clean up the peaker checker when we shut down the writer + # Clean up the peak checker when we shut down the writer self._cancel_peak_checker() @callback @@ -146,11 +165,9 @@ class WebSocketHandler: if isinstance(message, dict): message = message_to_json(message) - to_write = self._to_write - - try: - to_write.put_nowait(message) - except asyncio.QueueFull: + message_queue = self._message_queue + queue_size_before_add = len(message_queue) + if queue_size_before_add >= MAX_PENDING_MSG: self._logger.error( ( "%s: Client unable to keep up with pending messages. Reached %s pending" @@ -162,10 +179,15 @@ class WebSocketHandler: message, ) self._cancel() + return + + message_queue.append(message) + if self._ready_future and not self._ready_future.done(): + self._ready_future.set_result(None) peak_checker_active = self._peak_checker_unsub is not None - if to_write.qsize() < PENDING_MSG_PEAK: + if queue_size_before_add <= PENDING_MSG_PEAK: if peak_checker_active: self._cancel_peak_checker() return @@ -180,7 +202,7 @@ class WebSocketHandler: """Check that we are no longer above the write peak.""" self._peak_checker_unsub = None - if self._to_write.qsize() < PENDING_MSG_PEAK: + if len(self._message_queue) < PENDING_MSG_PEAK: return self._logger.error( @@ -199,6 +221,7 @@ class WebSocketHandler: def _cancel(self) -> None: """Cancel the connection.""" self._closing = True + self._cancel_peak_checker() if self._handle_task is not None: self._handle_task.cancel() if self._writer_task is not None: @@ -356,14 +379,14 @@ class WebSocketHandler: self._closing = True + self._message_queue.append(None) + if self._ready_future and not self._ready_future.done(): + self._ready_future.set_result(None) + try: - self._to_write.put_nowait(None) # Make sure all error messages are written before closing await self._writer_task await wsock.close() - except asyncio.QueueFull: # can be raised by put_nowait - self._writer_task.cancel() - finally: if disconnect_warn is None: self._logger.debug("Disconnected") diff --git a/tests/components/websocket_api/test_http.py b/tests/components/websocket_api/test_http.py index 475fbeee7658..8e47e7fca2eb 100644 --- a/tests/components/websocket_api/test_http.py +++ b/tests/components/websocket_api/test_http.py @@ -1,7 +1,7 @@ """Test Websocket API http module.""" import asyncio from datetime import timedelta -from typing import Any +from typing import Any, cast from unittest.mock import patch from aiohttp import ServerDisconnectedError, WSMsgType, web @@ -53,12 +53,12 @@ async def test_pending_msg_peak( ) -> None: """Test pending msg overflow command.""" orig_handler = http.WebSocketHandler - instance = None + setup_instance: http.WebSocketHandler | None = None def instantiate_handler(*args): - nonlocal instance - instance = orig_handler(*args) - return instance + nonlocal setup_instance + setup_instance = orig_handler(*args) + return setup_instance with patch( "homeassistant.components.websocket_api.http.WebSocketHandler", @@ -66,12 +66,11 @@ async def test_pending_msg_peak( ): websocket_client = await hass_ws_client() - # Kill writer task and fill queue past peak - for _ in range(5): - instance._to_write.put_nowait(None) + instance: http.WebSocketHandler = cast(http.WebSocketHandler, setup_instance) - # Trigger the peak check - instance._send_message({}) + # Fill the queue past the allowed peak + for _ in range(10): + instance._send_message({}) async_fire_time_changed( hass, utcnow() + timedelta(seconds=const.PENDING_MSG_PEAK_TIME + 1) @@ -79,8 +78,54 @@ async def test_pending_msg_peak( msg = await websocket_client.receive() assert msg.type == WSMsgType.close - assert "Client unable to keep up with pending messages" in caplog.text + assert "Stayed over 5 for 5 seconds" + + +async def test_pending_msg_peak_recovery( + hass: HomeAssistant, + mock_low_peak, + hass_ws_client: WebSocketGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test pending msg nears the peak but recovers.""" + orig_handler = http.WebSocketHandler + setup_instance: http.WebSocketHandler | None = None + + def instantiate_handler(*args): + nonlocal setup_instance + setup_instance = orig_handler(*args) + return setup_instance + + with patch( + "homeassistant.components.websocket_api.http.WebSocketHandler", + instantiate_handler, + ): + websocket_client = await hass_ws_client() + + instance: http.WebSocketHandler = cast(http.WebSocketHandler, setup_instance) + + # Make sure the call later is started + for _ in range(10): + instance._send_message({}) + + for _ in range(10): + msg = await websocket_client.receive() + assert msg.type == WSMsgType.TEXT + + instance._send_message({}) + msg = await websocket_client.receive() + assert msg.type == WSMsgType.TEXT + + # Cleanly shutdown + instance._send_message({}) + instance._handle_task.cancel() + + msg = await websocket_client.receive() + assert msg.type == WSMsgType.TEXT + msg = await websocket_client.receive() + assert msg.type == WSMsgType.close + assert "Client unable to keep up with pending messages" not in caplog.text async def test_pending_msg_peak_but_does_not_overflow( @@ -91,12 +136,12 @@ async def test_pending_msg_peak_but_does_not_overflow( ) -> None: """Test pending msg hits the low peak but recovers and does not overflow.""" orig_handler = http.WebSocketHandler - instance: http.WebSocketHandler | None = None + setup_instance: http.WebSocketHandler | None = None def instantiate_handler(*args): - nonlocal instance - instance = orig_handler(*args) - return instance + nonlocal setup_instance + setup_instance = orig_handler(*args) + return setup_instance with patch( "homeassistant.components.websocket_api.http.WebSocketHandler", @@ -104,18 +149,17 @@ async def test_pending_msg_peak_but_does_not_overflow( ): websocket_client = await hass_ws_client() - assert instance is not None + instance: http.WebSocketHandler = cast(http.WebSocketHandler, setup_instance) # Kill writer task and fill queue past peak for _ in range(5): - instance._to_write.put_nowait(None) + instance._message_queue.append(None) # Trigger the peak check instance._send_message({}) # Clear the queue - while instance._to_write.qsize() > 0: - instance._to_write.get_nowait() + instance._message_queue.clear() # Trigger the peak clear instance._send_message({}) From 051d631504c739b8b40fe91907865e12b8d355dd Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Fri, 12 May 2023 18:48:32 +0200 Subject: [PATCH 336/822] Poll Reolink motion when ONVIF not available (#92837) Co-authored-by: J. Nick Koston --- homeassistant/components/reolink/host.py | 47 ++++++++++++++++++- homeassistant/components/reolink/strings.json | 2 +- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index 8dad658d7db2..5ff79029bd1c 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -14,10 +14,11 @@ from reolink_aio.exceptions import ReolinkError, SubscriptionError from homeassistant.components import webhook from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME -from homeassistant.core import HomeAssistant +from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_call_later from homeassistant.helpers.network import NoURLAvailableError, get_url from .const import CONF_PROTOCOL, CONF_USE_HTTPS, DOMAIN @@ -26,6 +27,7 @@ from .exceptions import ReolinkSetupException, ReolinkWebhookException, UserNotA DEFAULT_TIMEOUT = 60 FIRST_ONVIF_TIMEOUT = 15 SUBSCRIPTION_RENEW_THRESHOLD = 300 +POLL_INTERVAL_NO_PUSH = 5 _LOGGER = logging.getLogger(__name__) @@ -59,6 +61,8 @@ class ReolinkHost: self._base_url: str = "" self._webhook_url: str = "" self._webhook_reachable: asyncio.Event = asyncio.Event() + self._cancel_poll: CALLBACK_TYPE | None = None + self._poll_job = HassJob(self._async_poll_all_motion, cancel_on_shutdown=True) self._lost_subscription: bool = False @property @@ -169,6 +173,7 @@ class ReolinkHost: "network_link": "https://my.home-assistant.io/redirect/network/", }, ) + await self._async_poll_all_motion() else: ir.async_delete_issue(self._hass, DOMAIN, "webhook_url") @@ -228,6 +233,9 @@ class ReolinkHost: async def stop(self, event=None): """Disconnect the API.""" + if self._cancel_poll is not None: + self._cancel_poll() + self._cancel_poll = None self.unregister_webhook() await self.disconnect() @@ -359,6 +367,43 @@ class ReolinkHost: webhook.async_unregister(self._hass, self.webhook_id) self.webhook_id = None + async def _async_poll_all_motion(self, *_) -> None: + """Poll motion and AI states until the first ONVIF push is received.""" + if self._webhook_reachable.is_set(): + # ONVIF push is working, stop polling + self._cancel_poll = None + return + + try: + await self._api.get_motion_state_all_ch() + except ( + aiohttp.ClientConnectorError, + ReolinkError, + ) as err: + _LOGGER.error( + "Reolink error while polling motion state for host %s:%s: %s", + self._api.host, + self._api.port, + str(err), + ) + except asyncio.TimeoutError: + _LOGGER.error( + "Reolink timeout error while polling motion state for host %s:%s", + self._api.host, + self._api.port, + ) + finally: + # schedule next poll + if not self._hass.is_stopping: + self._cancel_poll = async_call_later( + self._hass, POLL_INTERVAL_NO_PUSH, self._poll_job + ) + + # After receiving the new motion states in the upstream lib, + # update the binary sensors with async_write_ha_state + # The same dispatch as for the webhook can be used + async_dispatcher_send(self._hass, f"{self.webhook_id}_all", {}) + async def handle_webhook( self, hass: HomeAssistant, webhook_id: str, request: Request ) -> None: diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index d02dbb8ab4d7..f208e3e4035f 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -45,7 +45,7 @@ }, "webhook_url": { "title": "Reolink webhook URL unreachable", - "description": "Did not receive initial ONVIF state from {name}. Most likely, the Reolink camera can not reach the current (local) Home Assistant URL `{base_url}`, please configure a (local) HTTP address under \"Home Assistant URL\" in the [network settings]({network_link}) that points to Home Assistant. For example `http://192.168.1.10:8123` where `192.168.1.10` is the IP of the Home Assistant device. Also, make sure the Reolink camera can reach that URL." + "description": "Did not receive initial ONVIF state from {name}. Most likely, the Reolink camera can not reach the current (local) Home Assistant URL `{base_url}`, please configure a (local) HTTP address under \"Home Assistant URL\" in the [network settings]({network_link}) that points to Home Assistant. For example `http://192.168.1.10:8123` where `192.168.1.10` is the IP of the Home Assistant device. Also, make sure the Reolink camera can reach that URL. Using fast motion/AI state polling until the first ONVIF push is received." }, "enable_port": { "title": "Reolink port not enabled", From 81796fbf7bf3fff21cf0af7f19d62b12b1848fed Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 13 May 2023 16:06:32 +0900 Subject: [PATCH 337/822] Bump orjson to 3.8.12 (#92935) changes: https://github.com/ijl/orjson/releases/tag/3.8.12 --- 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 396e91b715af..ae2b0fef90b1 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ janus==1.0.0 jinja2==3.1.2 lru-dict==1.1.8 mutagen==1.46.0 -orjson==3.8.11 +orjson==3.8.12 paho-mqtt==1.6.1 pillow==9.5.0 pip>=21.0,<23.2 diff --git a/pyproject.toml b/pyproject.toml index 5027e8ae0d7a..11d0f910067d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,7 @@ dependencies = [ "cryptography==40.0.2", # pyOpenSSL 23.1.0 is required to work with cryptography 39+ "pyOpenSSL==23.1.0", - "orjson==3.8.11", + "orjson==3.8.12", "pip>=21.0,<23.2", "python-slugify==4.0.1", "pyyaml==6.0", diff --git a/requirements.txt b/requirements.txt index 75a5d6269ef9..5931440dccb3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,7 +18,7 @@ lru-dict==1.1.8 PyJWT==2.6.0 cryptography==40.0.2 pyOpenSSL==23.1.0 -orjson==3.8.11 +orjson==3.8.12 pip>=21.0,<23.2 python-slugify==4.0.1 pyyaml==6.0 From 01e131b278cb8cd2da3a7eb15e360778a14581e8 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 13 May 2023 10:42:04 +0200 Subject: [PATCH 338/822] Fix already_configured string in workday (#92901) * Fix already_configured string in workday * Fix strings --- homeassistant/components/workday/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/workday/strings.json b/homeassistant/components/workday/strings.json index 12ab729ff1b0..8a516602b56b 100644 --- a/homeassistant/components/workday/strings.json +++ b/homeassistant/components/workday/strings.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "incorrect_province": "Incorrect subdivision from yaml import" + "incorrect_province": "Incorrect subdivision from yaml import", + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" }, "step": { "user": { @@ -31,8 +32,7 @@ }, "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", - "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + "remove_holiday_error": "Incorrect format on date (YYYY-MM-DD) or holiday name not found" } }, "options": { @@ -59,7 +59,7 @@ "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", - "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + "already_configured": "Service with this configuration already exist" } }, "issues": { From 0437926db88c722c6ba6a7ebb28492f128c83423 Mon Sep 17 00:00:00 2001 From: Justas Date: Sat, 13 May 2023 21:03:20 +0300 Subject: [PATCH 339/822] Adding newly supported language codes to Google TTS (#93030) --- homeassistant/components/google_cloud/tts.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/google_cloud/tts.py b/homeassistant/components/google_cloud/tts.py index 0de580ef7b7a..89da3433815b 100644 --- a/homeassistant/components/google_cloud/tts.py +++ b/homeassistant/components/google_cloud/tts.py @@ -40,11 +40,14 @@ SUPPORTED_LANGUAGES = [ "en-US", "es-ES", "es-US", + "eu-ES", "fi-FI", "fil-PH", "fr-CA", "fr-FR", + "gl-ES", "gu-IN", + "he-IL", "hi-IN", "hu-HU", "id-ID", @@ -54,7 +57,9 @@ SUPPORTED_LANGUAGES = [ "kn-IN", "ko-KR", "lv-LV", + "lt-LT", "ml-IN", + "mr-IN", "ms-MY", "nb-NO", "nl-BE", From 61cd36d628a359d14723c435cd713058bb03115a Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Sat, 13 May 2023 14:06:22 -0400 Subject: [PATCH 340/822] Bump bellows to 0.35.5 to fix Aqara Zigbee connectivity issue (#92999) Bump bellows to 0.35.5 --- 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 1055d846544e..46fe2ce472a6 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -20,7 +20,7 @@ "zigpy_znp" ], "requirements": [ - "bellows==0.35.3", + "bellows==0.35.5", "pyserial==3.5", "pyserial-asyncio==0.6", "zha-quirks==0.0.99", diff --git a/requirements_all.txt b/requirements_all.txt index 0ebb41873978..efc89ce66a01 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -428,7 +428,7 @@ beautifulsoup4==4.11.1 # beewi_smartclim==0.0.10 # homeassistant.components.zha -bellows==0.35.3 +bellows==0.35.5 # homeassistant.components.bmw_connected_drive bimmer_connected==0.13.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f93b1f5b6635..07a71a6a8409 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -361,7 +361,7 @@ base36==0.1.1 beautifulsoup4==4.11.1 # homeassistant.components.zha -bellows==0.35.3 +bellows==0.35.5 # homeassistant.components.bmw_connected_drive bimmer_connected==0.13.3 From a9778c4236f816c65a2eda288ca4ee3dd4852c0b Mon Sep 17 00:00:00 2001 From: imerla1 <36087671+imerla1@users.noreply.github.com> Date: Sat, 13 May 2023 23:05:55 +0400 Subject: [PATCH 341/822] fixed typo (#93029) --- homeassistant/components/xiaomi_aqara/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/xiaomi_aqara/config_flow.py b/homeassistant/components/xiaomi_aqara/config_flow.py index 773e63d92bfb..2fae1796e45e 100644 --- a/homeassistant/components/xiaomi_aqara/config_flow.py +++ b/homeassistant/components/xiaomi_aqara/config_flow.py @@ -105,7 +105,7 @@ class XiaomiAqaraFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_settings() - # Discover Xiaomi Aqara Gateways in the netwerk to get required SIDs. + # Discover Xiaomi Aqara Gateways in the network to get required SIDs. xiaomi = XiaomiGatewayDiscovery(self.interface) try: await self.hass.async_add_executor_job(xiaomi.discover_gateways) From 7d371a33bb7d7bc22c110bbdd5a9da371e237f5c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 13 May 2023 19:15:02 -0500 Subject: [PATCH 342/822] Disable cleanup_closed for aiohttp.TCPConnector with cpython 3.11.1+ (#93013) * Disable cleanup_closed for aiohttp.TCPConnector with cpython 3.11.2+ There is currently a relatively fast memory leak when using cpython 3.11.2+ and cleanup_closed with aiohttp For my production instance it was leaking ~450MiB per day of `MemoryBIO`, `SSLProtocol`, `SSLObject`, `_SSLProtocolTransport` `memoryview`, and `managedbuffer` objects see https://github.com/aio-libs/aiohttp/issues/7252 see https://github.com/python/cpython/pull/98540 * Update homeassistant/helpers/aiohttp_client.py --- homeassistant/helpers/aiohttp_client.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index 78a8051df1cc..78806cb5ae17 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -37,6 +37,11 @@ SERVER_SOFTWARE = "{0}/{1} aiohttp/{2} Python/{3[0]}.{3[1]}".format( APPLICATION_NAME, __version__, aiohttp.__version__, sys.version_info ) +ENABLE_CLEANUP_CLOSED = sys.version_info < (3, 11, 1) +# Enabling cleanup closed on python 3.11.1+ leaks memory relatively quickly +# see https://github.com/aio-libs/aiohttp/issues/7252 +# aiohttp interacts poorly with https://github.com/python/cpython/pull/98540 + WARN_CLOSE_MSG = "closes the Home Assistant aiohttp session" # @@ -276,7 +281,7 @@ def _async_get_connector( ssl_context = ssl_util.get_default_no_verify_context() connector = aiohttp.TCPConnector( - enable_cleanup_closed=True, + enable_cleanup_closed=ENABLE_CLEANUP_CLOSED, ssl=ssl_context, limit=MAXIMUM_CONNECTIONS, limit_per_host=MAXIMUM_CONNECTIONS_PER_HOST, From e593ceaaf2acc7ae22a1086fc34e915dbd88413c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 13 May 2023 19:16:11 -0500 Subject: [PATCH 343/822] Fix sslv2/sslv3 with unverified connections (#93037) In #90191 we use the same ssl context for httpx now to avoid a memory leak, but httpx previously allowed sslv2/sslv3 for unverified connections This reverts to the behavior before #90191 --- homeassistant/util/ssl.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/util/ssl.py b/homeassistant/util/ssl.py index aa1b933e0ae7..664d6f15650e 100644 --- a/homeassistant/util/ssl.py +++ b/homeassistant/util/ssl.py @@ -73,8 +73,6 @@ def create_no_verify_ssl_context( https://github.com/aio-libs/aiohttp/blob/33953f110e97eecc707e1402daa8d543f38a189b/aiohttp/connector.py#L911 """ sslcontext = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) - sslcontext.options |= ssl.OP_NO_SSLv2 - sslcontext.options |= ssl.OP_NO_SSLv3 sslcontext.check_hostname = False sslcontext.verify_mode = ssl.CERT_NONE with contextlib.suppress(AttributeError): From 637941df4dc271e93110cb846303161f06acd15a Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Sun, 14 May 2023 10:07:15 -0600 Subject: [PATCH 344/822] Fix a series of bugs due to Notion API changes (#93039) * Fix a series of bugs due to Notion API changes * Simplify * Reduce blast radius * Reduce blast radius * Fix tests --- homeassistant/components/notion/__init__.py | 96 ++++++++++------ .../components/notion/binary_sensor.py | 27 ++--- .../components/notion/diagnostics.py | 2 + homeassistant/components/notion/manifest.json | 2 +- homeassistant/components/notion/sensor.py | 35 +++--- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/notion/conftest.py | 30 +++-- .../notion/fixtures/bridge_data.json | 98 ++++++++-------- .../notion/fixtures/listener_data.json | 106 +++++++++--------- .../notion/fixtures/sensor_data.json | 70 ++++++------ .../fixtures/user_preferences_data.json | 10 ++ tests/components/notion/test_diagnostics.py | 24 ++-- 13 files changed, 283 insertions(+), 221 deletions(-) create mode 100644 tests/components/notion/fixtures/user_preferences_data.json diff --git a/homeassistant/components/notion/__init__.py b/homeassistant/components/notion/__init__.py index 5e55496fc541..ad228f08a4b1 100644 --- a/homeassistant/components/notion/__init__.py +++ b/homeassistant/components/notion/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from dataclasses import dataclass, field, fields +from dataclasses import dataclass, field from datetime import timedelta import logging import traceback @@ -10,9 +10,16 @@ from typing import Any from uuid import UUID from aionotion import async_get_client -from aionotion.bridge.models import Bridge +from aionotion.bridge.models import Bridge, BridgeAllResponse from aionotion.errors import InvalidCredentialsError, NotionError -from aionotion.sensor.models import Listener, ListenerKind, Sensor +from aionotion.sensor.models import ( + Listener, + ListenerAllResponse, + ListenerKind, + Sensor, + SensorAllResponse, +) +from aionotion.user.models import UserPreferences, UserPreferencesResponse from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform @@ -51,6 +58,11 @@ PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] ATTR_SYSTEM_MODE = "system_mode" ATTR_SYSTEM_NAME = "system_name" +DATA_BRIDGES = "bridges" +DATA_LISTENERS = "listeners" +DATA_SENSORS = "sensors" +DATA_USER_PREFERENCES = "user_preferences" + DEFAULT_SCAN_INTERVAL = timedelta(minutes=1) CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) @@ -84,6 +96,9 @@ def is_uuid(value: str) -> bool: class NotionData: """Define a manager class for Notion data.""" + hass: HomeAssistant + entry: ConfigEntry + # Define a dict of bridges, indexed by bridge ID (an integer): bridges: dict[int, Bridge] = field(default_factory=dict) @@ -93,12 +108,40 @@ class NotionData: # Define a dict of sensors, indexed by sensor UUID (a string): sensors: dict[str, Sensor] = field(default_factory=dict) + # Define a user preferences response object: + user_preferences: UserPreferences | None = field(default=None) + + def update_data_from_response( + self, + response: BridgeAllResponse + | ListenerAllResponse + | SensorAllResponse + | UserPreferencesResponse, + ) -> None: + """Update data from an aionotion response.""" + if isinstance(response, BridgeAllResponse): + for bridge in response.bridges: + # If a new bridge is discovered, register it: + if bridge.id not in self.bridges: + _async_register_new_bridge(self.hass, self.entry, bridge) + self.bridges[bridge.id] = bridge + elif isinstance(response, ListenerAllResponse): + self.listeners = {listener.id: listener for listener in response.listeners} + elif isinstance(response, SensorAllResponse): + self.sensors = {sensor.uuid: sensor for sensor in response.sensors} + elif isinstance(response, UserPreferencesResponse): + self.user_preferences = response.user_preferences + def asdict(self) -> dict[str, Any]: """Represent this dataclass (and its Pydantic contents) as a dict.""" - return { - field.name: [obj.dict() for obj in getattr(self, field.name).values()] - for field in fields(self) + data: dict[str, Any] = { + DATA_BRIDGES: [bridge.dict() for bridge in self.bridges.values()], + DATA_LISTENERS: [listener.dict() for listener in self.listeners.values()], + DATA_SENSORS: [sensor.dict() for sensor in self.sensors.values()], } + if self.user_preferences: + data[DATA_USER_PREFERENCES] = self.user_preferences.dict() + return data async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -121,11 +164,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_update() -> NotionData: """Get the latest data from the Notion API.""" - data = NotionData() + data = NotionData(hass=hass, entry=entry) tasks = { - "bridges": client.bridge.async_all(), - "listeners": client.sensor.async_listeners(), - "sensors": client.sensor.async_all(), + DATA_BRIDGES: client.bridge.async_all(), + DATA_LISTENERS: client.sensor.async_listeners(), + DATA_SENSORS: client.sensor.async_all(), + DATA_USER_PREFERENCES: client.user.async_preferences(), } results = await asyncio.gather(*tasks.values(), return_exceptions=True) @@ -145,16 +189,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: f"There was an unknown error while updating {attr}: {result}" ) from result - for item in result: - if attr == "bridges": - # If a new bridge is discovered, register it: - if item.id not in data.bridges: - _async_register_new_bridge(hass, item, entry) - data.bridges[item.id] = item - elif attr == "listeners": - data.listeners[item.id] = item - else: - data.sensors[item.uuid] = item + data.update_data_from_response(result) return data @@ -216,7 +251,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @callback def _async_register_new_bridge( - hass: HomeAssistant, bridge: Bridge, entry: ConfigEntry + hass: HomeAssistant, entry: ConfigEntry, bridge: Bridge ) -> None: """Register a new bridge.""" if name := bridge.name: @@ -279,6 +314,11 @@ class NotionEntity(CoordinatorEntity[DataUpdateCoordinator[NotionData]]): and self._listener_id in self.coordinator.data.listeners ) + @property + def listener(self) -> Listener: + """Return the listener related to this entity.""" + return self.coordinator.data.listeners[self._listener_id] + @callback def _async_update_bridge_id(self) -> None: """Update the entity's bridge ID if it has changed. @@ -310,21 +350,9 @@ class NotionEntity(CoordinatorEntity[DataUpdateCoordinator[NotionData]]): this_device.id, via_device_id=bridge_device.id ) - @callback - def _async_update_from_latest_data(self) -> None: - """Update the entity from the latest data.""" - raise NotImplementedError - @callback def _handle_coordinator_update(self) -> None: """Respond to a DataUpdateCoordinator update.""" if self._listener_id in self.coordinator.data.listeners: self._async_update_bridge_id() - self._async_update_from_latest_data() - - self.async_write_ha_state() - - async def async_added_to_hass(self) -> None: - """Handle entity which will be added.""" - await super().async_added_to_hass() - self._async_update_from_latest_data() + super()._handle_coordinator_update() diff --git a/homeassistant/components/notion/binary_sensor.py b/homeassistant/components/notion/binary_sensor.py index bd2de303d2de..f70af18c3e15 100644 --- a/homeassistant/components/notion/binary_sensor.py +++ b/homeassistant/components/notion/binary_sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import NotionEntity @@ -37,7 +37,7 @@ from .model import NotionEntityDescriptionMixin class NotionBinarySensorDescriptionMixin: """Define an entity description mixin for binary and regular sensors.""" - on_state: Literal["alarm", "critical", "leak", "not_missing", "open"] + on_state: Literal["alarm", "leak", "low", "not_missing", "open"] @dataclass @@ -56,7 +56,7 @@ BINARY_SENSOR_DESCRIPTIONS = ( device_class=BinarySensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, listener_kind=ListenerKind.BATTERY, - on_state="critical", + on_state="low", ), NotionBinarySensorDescription( key=SENSOR_DOOR, @@ -146,17 +146,10 @@ class NotionBinarySensor(NotionEntity, BinarySensorEntity): entity_description: NotionBinarySensorDescription - @callback - def _async_update_from_latest_data(self) -> None: - """Fetch new state data for the sensor.""" - listener = self.coordinator.data.listeners[self._listener_id] - - if listener.status.trigger_value: - state = listener.status.trigger_value - elif listener.insights.primary.value: - state = listener.insights.primary.value - else: - LOGGER.warning("Unknown listener structure: %s", listener) - state = None - - self._attr_is_on = self.entity_description.on_state == state + @property + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + if not self.listener.insights.primary.value: + LOGGER.warning("Unknown listener structure: %s", self.listener.dict()) + return False + return self.listener.insights.primary.value == self.entity_description.on_state diff --git a/homeassistant/components/notion/diagnostics.py b/homeassistant/components/notion/diagnostics.py index 06100580b395..86b84760016f 100644 --- a/homeassistant/components/notion/diagnostics.py +++ b/homeassistant/components/notion/diagnostics.py @@ -16,6 +16,7 @@ CONF_DEVICE_KEY = "device_key" CONF_HARDWARE_ID = "hardware_id" CONF_LAST_BRIDGE_HARDWARE_ID = "last_bridge_hardware_id" CONF_TITLE = "title" +CONF_USER_ID = "user_id" TO_REDACT = { CONF_DEVICE_KEY, @@ -27,6 +28,7 @@ TO_REDACT = { CONF_TITLE, CONF_UNIQUE_ID, CONF_USERNAME, + CONF_USER_ID, } diff --git a/homeassistant/components/notion/manifest.json b/homeassistant/components/notion/manifest.json index f0952089ba82..168899c38e0f 100644 --- a/homeassistant/components/notion/manifest.json +++ b/homeassistant/components/notion/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["aionotion"], - "requirements": ["aionotion==2023.05.1"] + "requirements": ["aionotion==2023.05.4"] } diff --git a/homeassistant/components/notion/sensor.py b/homeassistant/components/notion/sensor.py index f4e6e7cc3220..e6ff3eaab694 100644 --- a/homeassistant/components/notion/sensor.py +++ b/homeassistant/components/notion/sensor.py @@ -11,11 +11,11 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTemperature -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import NotionEntity -from .const import DOMAIN, LOGGER, SENSOR_TEMPERATURE +from .const import DOMAIN, SENSOR_TEMPERATURE from .model import NotionEntityDescriptionMixin @@ -63,15 +63,24 @@ async def async_setup_entry( class NotionSensor(NotionEntity, SensorEntity): """Define a Notion sensor.""" - @callback - def _async_update_from_latest_data(self) -> None: - """Fetch new state data for the sensor.""" - listener = self.coordinator.data.listeners[self._listener_id] + @property + def native_unit_of_measurement(self) -> str | None: + """Return the unit of measurement of the sensor.""" + if self.listener.listener_kind == ListenerKind.TEMPERATURE: + if not self.coordinator.data.user_preferences: + return None + if self.coordinator.data.user_preferences.celsius_enabled: + return UnitOfTemperature.CELSIUS + return UnitOfTemperature.FAHRENHEIT + return None - if listener.listener_kind == ListenerKind.TEMPERATURE: - self._attr_native_value = round(listener.status.temperature, 1) # type: ignore[attr-defined] - else: - LOGGER.error( - "Unknown listener type for sensor %s", - self.coordinator.data.sensors[self._sensor_id], - ) + @property + def native_value(self) -> str | None: + """Return the value reported by the sensor. + + The Notion API only returns a localized string for temperature (e.g. "70°"); we + simply remove the degree symbol: + """ + if not self.listener.status_localized: + return None + return self.listener.status_localized.state[:-1] diff --git a/requirements_all.txt b/requirements_all.txt index efc89ce66a01..2648aec0b9a6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -223,7 +223,7 @@ aionanoleaf==0.2.1 aionotify==0.2.0 # homeassistant.components.notion -aionotion==2023.05.1 +aionotion==2023.05.4 # homeassistant.components.oncue aiooncue==0.3.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 07a71a6a8409..5c30dc4da572 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -204,7 +204,7 @@ aiomusiccast==0.14.8 aionanoleaf==0.2.1 # homeassistant.components.notion -aionotion==2023.05.1 +aionotion==2023.05.4 # homeassistant.components.oncue aiooncue==0.3.4 diff --git a/tests/components/notion/conftest.py b/tests/components/notion/conftest.py index 75eeda70300c..81d69158e823 100644 --- a/tests/components/notion/conftest.py +++ b/tests/components/notion/conftest.py @@ -3,8 +3,9 @@ from collections.abc import Generator import json from unittest.mock import AsyncMock, Mock, patch -from aionotion.bridge.models import Bridge -from aionotion.sensor.models import Listener, Sensor +from aionotion.bridge.models import BridgeAllResponse +from aionotion.sensor.models import ListenerAllResponse, SensorAllResponse +from aionotion.user.models import UserPreferencesResponse import pytest from homeassistant.components.notion import DOMAIN @@ -27,24 +28,23 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: @pytest.fixture(name="client") -def client_fixture(data_bridge, data_listener, data_sensor): +def client_fixture(data_bridge, data_listener, data_sensor, data_user_preferences): """Define a fixture for an aionotion client.""" return Mock( bridge=Mock( - async_all=AsyncMock( - return_value=[Bridge.parse_obj(bridge) for bridge in data_bridge] - ) + async_all=AsyncMock(return_value=BridgeAllResponse.parse_obj(data_bridge)) ), sensor=Mock( - async_all=AsyncMock( - return_value=[Sensor.parse_obj(sensor) for sensor in data_sensor] - ), + async_all=AsyncMock(return_value=SensorAllResponse.parse_obj(data_sensor)), async_listeners=AsyncMock( - return_value=[ - Listener.parse_obj(listener) for listener in data_listener - ] + return_value=ListenerAllResponse.parse_obj(data_listener) ), ), + user=Mock( + async_preferences=AsyncMock( + return_value=UserPreferencesResponse.parse_obj(data_user_preferences) + ) + ), ) @@ -83,6 +83,12 @@ def data_sensor_fixture(): return json.loads(load_fixture("sensor_data.json", "notion")) +@pytest.fixture(name="data_user_preferences", scope="package") +def data_user_preferences_fixture(): + """Define user preferences data.""" + return json.loads(load_fixture("user_preferences_data.json", "notion")) + + @pytest.fixture(name="get_client") def get_client_fixture(client): """Define a fixture to mock the async_get_client method.""" diff --git a/tests/components/notion/fixtures/bridge_data.json b/tests/components/notion/fixtures/bridge_data.json index 008967ece866..05bd8859e7e8 100644 --- a/tests/components/notion/fixtures/bridge_data.json +++ b/tests/components/notion/fixtures/bridge_data.json @@ -1,50 +1,52 @@ -[ - { - "id": 12345, - "name": "Bridge 1", - "mode": "home", - "hardware_id": "0x0000000000000000", - "hardware_revision": 4, - "firmware_version": { - "silabs": "1.1.2", - "wifi": "0.121.0", - "wifi_app": "3.3.0" +{ + "base_stations": [ + { + "id": 12345, + "name": "Bridge 1", + "mode": "home", + "hardware_id": "0x0000000000000000", + "hardware_revision": 4, + "firmware_version": { + "silabs": "1.1.2", + "wifi": "0.121.0", + "wifi_app": "3.3.0" + }, + "missing_at": null, + "created_at": "2019-06-27T00:18:44.337Z", + "updated_at": "2023-03-19T03:20:16.061Z", + "system_id": 11111, + "firmware": { + "silabs": "1.1.2", + "wifi": "0.121.0", + "wifi_app": "3.3.0" + }, + "links": { + "system": 11111 + } }, - "missing_at": null, - "created_at": "2019-06-27T00:18:44.337Z", - "updated_at": "2023-03-19T03:20:16.061Z", - "system_id": 11111, - "firmware": { - "silabs": "1.1.2", - "wifi": "0.121.0", - "wifi_app": "3.3.0" - }, - "links": { - "system": 11111 + { + "id": 67890, + "name": "Bridge 2", + "mode": "home", + "hardware_id": "0x0000000000000000", + "hardware_revision": 4, + "firmware_version": { + "wifi": "0.121.0", + "wifi_app": "3.3.0", + "silabs": "1.1.2" + }, + "missing_at": null, + "created_at": "2019-04-30T01:43:50.497Z", + "updated_at": "2023-01-02T19:09:58.251Z", + "system_id": 11111, + "firmware": { + "wifi": "0.121.0", + "wifi_app": "3.3.0", + "silabs": "1.1.2" + }, + "links": { + "system": 11111 + } } - }, - { - "id": 67890, - "name": "Bridge 2", - "mode": "home", - "hardware_id": "0x0000000000000000", - "hardware_revision": 4, - "firmware_version": { - "wifi": "0.121.0", - "wifi_app": "3.3.0", - "silabs": "1.1.2" - }, - "missing_at": null, - "created_at": "2019-04-30T01:43:50.497Z", - "updated_at": "2023-01-02T19:09:58.251Z", - "system_id": 11111, - "firmware": { - "wifi": "0.121.0", - "wifi_app": "3.3.0", - "silabs": "1.1.2" - }, - "links": { - "system": 11111 - } - } -] + ] +} diff --git a/tests/components/notion/fixtures/listener_data.json b/tests/components/notion/fixtures/listener_data.json index bd49aab89dbe..6d59dde76df2 100644 --- a/tests/components/notion/fixtures/listener_data.json +++ b/tests/components/notion/fixtures/listener_data.json @@ -1,55 +1,57 @@ -[ - { - "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - "definition_id": 4, - "created_at": "2019-06-28T22:12:49.651Z", - "type": "sensor", - "model_version": "2.1", - "sensor_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - "status": { - "trigger_value": "no_leak", - "data_received_at": "2022-03-20T08:00:29.763Z" - }, - "status_localized": { - "state": "No Leak", - "description": "Mar 20 at 2:00am" - }, - "insights": { - "primary": { - "origin": { - "type": "Sensor", - "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" - }, - "value": "no_leak", +{ + "listeners": [ + { + "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "definition_id": 4, + "created_at": "2019-06-28T22:12:49.651Z", + "type": "sensor", + "model_version": "2.1", + "sensor_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "status": { + "trigger_value": "no_leak", "data_received_at": "2022-03-20T08:00:29.763Z" - } + }, + "status_localized": { + "state": "No Leak", + "description": "Mar 20 at 2:00am" + }, + "insights": { + "primary": { + "origin": { + "type": "Sensor", + "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + }, + "value": "no_leak", + "data_received_at": "2022-03-20T08:00:29.763Z" + } + }, + "configuration": {}, + "pro_monitoring_status": "eligible" }, - "configuration": {}, - "pro_monitoring_status": "eligible" - }, - { - "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - "definition_id": 7, - "created_at": "2019-07-10T22:40:48.847Z", - "type": "sensor", - "model_version": "3.1", - "sensor_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - "status": { - "trigger_value": "no_alarm", - "data_received_at": "2019-06-28T22:12:49.516Z" - }, - "status_localized": { - "state": "No Sound", - "description": "Jun 28 at 4:12pm" - }, - "insights": { - "primary": { - "origin": {}, - "value": "no_alarm", + { + "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "definition_id": 7, + "created_at": "2019-07-10T22:40:48.847Z", + "type": "sensor", + "model_version": "3.1", + "sensor_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "status": { + "trigger_value": "no_alarm", "data_received_at": "2019-06-28T22:12:49.516Z" - } - }, - "configuration": {}, - "pro_monitoring_status": "eligible" - } -] + }, + "status_localized": { + "state": "No Sound", + "description": "Jun 28 at 4:12pm" + }, + "insights": { + "primary": { + "origin": {}, + "value": "no_alarm", + "data_received_at": "2019-06-28T22:12:49.516Z" + } + }, + "configuration": {}, + "pro_monitoring_status": "eligible" + } + ] +} diff --git a/tests/components/notion/fixtures/sensor_data.json b/tests/components/notion/fixtures/sensor_data.json index e042daf6dddf..9f0d0fe2e030 100644 --- a/tests/components/notion/fixtures/sensor_data.json +++ b/tests/components/notion/fixtures/sensor_data.json @@ -1,34 +1,36 @@ -[ - { - "id": 123456, - "uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - "user": { - "id": 12345, - "email": "user@email.com" - }, - "bridge": { - "id": 67890, - "hardware_id": "0x0000000000000000" - }, - "last_bridge_hardware_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - "name": "Sensor 1", - "location_id": 123456, - "system_id": 12345, - "hardware_id": "0x0000000000000000", - "hardware_revision": 5, - "firmware_version": "1.1.2", - "device_key": "0x0000000000000000", - "encryption_key": true, - "installed_at": "2019-06-28T22:12:51.209Z", - "calibrated_at": "2023-03-07T19:51:56.838Z", - "last_reported_at": "2023-04-19T18:09:40.479Z", - "missing_at": null, - "updated_at": "2023-03-28T13:33:33.801Z", - "created_at": "2019-06-28T22:12:20.256Z", - "signal_strength": 4, - "firmware": { - "status": "valid" - }, - "surface_type": null - } -] +{ + "sensors": [ + { + "id": 123456, + "uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "user": { + "id": 12345, + "email": "user@email.com" + }, + "bridge": { + "id": 67890, + "hardware_id": "0x0000000000000000" + }, + "last_bridge_hardware_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "name": "Sensor 1", + "location_id": 123456, + "system_id": 12345, + "hardware_id": "0x0000000000000000", + "hardware_revision": 5, + "firmware_version": "1.1.2", + "device_key": "0x0000000000000000", + "encryption_key": true, + "installed_at": "2019-06-28T22:12:51.209Z", + "calibrated_at": "2023-03-07T19:51:56.838Z", + "last_reported_at": "2023-04-19T18:09:40.479Z", + "missing_at": null, + "updated_at": "2023-03-28T13:33:33.801Z", + "created_at": "2019-06-28T22:12:20.256Z", + "signal_strength": 4, + "firmware": { + "status": "valid" + }, + "surface_type": null + } + ] +} diff --git a/tests/components/notion/fixtures/user_preferences_data.json b/tests/components/notion/fixtures/user_preferences_data.json new file mode 100644 index 000000000000..6fa603e9d854 --- /dev/null +++ b/tests/components/notion/fixtures/user_preferences_data.json @@ -0,0 +1,10 @@ +{ + "user_preferences": { + "user_id": 12345, + "military_time_enabled": false, + "celsius_enabled": false, + "disconnect_alerts_enabled": true, + "home_away_alerts_enabled": false, + "battery_alerts_enabled": true + } +} diff --git a/tests/components/notion/test_diagnostics.py b/tests/components/notion/test_diagnostics.py index 7062778e812d..b59b995b4040 100644 --- a/tests/components/notion/test_diagnostics.py +++ b/tests/components/notion/test_diagnostics.py @@ -86,14 +86,6 @@ async def test_entry_diagnostics( "device_type": "sensor", "model_version": "3.1", "sensor_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - "status": { - "trigger_value": "no_alarm", - "data_received_at": "2019-06-28T22:12:49.516000+00:00", - }, - "status_localized": { - "state": "No Sound", - "description": "Jun 28 at 4:12pm", - }, "insights": { "primary": { "origin": {"type": None, "id": None}, @@ -103,6 +95,14 @@ async def test_entry_diagnostics( }, "configuration": {}, "pro_monitoring_status": "eligible", + "status": { + "trigger_value": "no_alarm", + "data_received_at": "2019-06-28T22:12:49.516000+00:00", + }, + "status_localized": { + "state": "No Sound", + "description": "Jun 28 at 4:12pm", + }, } ], "sensors": [ @@ -131,5 +131,13 @@ async def test_entry_diagnostics( "surface_type": None, } ], + "user_preferences": { + "user_id": REDACTED, + "military_time_enabled": False, + "celsius_enabled": False, + "disconnect_alerts_enabled": True, + "home_away_alerts_enabled": False, + "battery_alerts_enabled": True, + }, }, } From e073f091b130677654c47ddba2cda7306652154c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 May 2023 11:07:38 -0500 Subject: [PATCH 345/822] Cache ip address stringify in zeroconf (#92800) stringify IPv6 addresses is expensive, and since zeroconf sees the same ones over and over again an LRU can avoid the constant stringify --- homeassistant/components/zeroconf/__init__.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index 17fb4c5856d1..9d7dd73be018 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -542,6 +542,12 @@ def async_get_homekit_discovery_domain( 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) + + def info_from_service(service: AsyncServiceInfo) -> ZeroconfServiceInfo | None: """Return prepared info from mDNS entries.""" properties: dict[str, Any] = {"_raw": {}} @@ -569,7 +575,7 @@ def info_from_service(service: AsyncServiceInfo) -> ZeroconfServiceInfo | None: host: str | None = None for ip_addr in ip_addresses: if not ip_addr.is_link_local and not ip_addr.is_unspecified: - host = str(ip_addr) + host = _stringify_ip_address(ip_addr) break if not host: return None @@ -577,7 +583,7 @@ 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=[str(ip_addr) for ip_addr in ip_addresses], + addresses=[_stringify_ip_address(ip_addr) for ip_addr in ip_addresses], port=service.port, hostname=service.server, type=service.type, From 2848f8648d3251a4a278aceffaa81ba9c2c64c7a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 May 2023 11:22:19 -0500 Subject: [PATCH 346/822] Log last message when websocket reaches peak limit (#93038) When we hit the absolute limit, we would log the last messages as it was key to finding out the source. We now do the same when we hit the peak limit --- homeassistant/components/websocket_api/http.py | 5 +++-- tests/components/websocket_api/test_http.py | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py index 7671d0ceaef5..310247709f82 100644 --- a/homeassistant/components/websocket_api/http.py +++ b/homeassistant/components/websocket_api/http.py @@ -172,7 +172,7 @@ class WebSocketHandler: ( "%s: Client unable to keep up with pending messages. Reached %s pending" " messages. The system's load is too high or an integration is" - " misbehaving. Last message was: %s" + " misbehaving; Last message was: %s" ), self.description, MAX_PENDING_MSG, @@ -209,11 +209,12 @@ class WebSocketHandler: ( "%s: Client unable to keep up with pending messages. Stayed over %s for %s" " seconds. The system's load is too high or an integration is" - " misbehaving" + " misbehaving; Last message was: %s" ), self.description, PENDING_MSG_PEAK, PENDING_MSG_PEAK_TIME, + self._message_queue[-1], ) self._cancel() diff --git a/tests/components/websocket_api/test_http.py b/tests/components/websocket_api/test_http.py index 8e47e7fca2eb..7c008f7515bb 100644 --- a/tests/components/websocket_api/test_http.py +++ b/tests/components/websocket_api/test_http.py @@ -70,7 +70,7 @@ async def test_pending_msg_peak( # Fill the queue past the allowed peak for _ in range(10): - instance._send_message({}) + instance._send_message({"overload": "message"}) async_fire_time_changed( hass, utcnow() + timedelta(seconds=const.PENDING_MSG_PEAK_TIME + 1) @@ -79,7 +79,8 @@ async def test_pending_msg_peak( msg = await websocket_client.receive() assert msg.type == WSMsgType.close assert "Client unable to keep up with pending messages" in caplog.text - assert "Stayed over 5 for 5 seconds" + assert "Stayed over 5 for 5 seconds" in caplog.text + assert "overload" in caplog.text async def test_pending_msg_peak_recovery( From 3314eed8d1f108a1a8c2a393161ab1291b6eb293 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 May 2023 11:23:31 -0500 Subject: [PATCH 347/822] Switch to using a ContextVar for template RenderInfo (#93016) The ContextVar is about 40% faster than the attr and dict lookups --- homeassistant/helpers/template.py | 46 +++++++++++++++---------------- 1 file changed, 22 insertions(+), 24 deletions(-) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 267e2e6c5656..6ff48d832417 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -89,7 +89,6 @@ _LOGGER = logging.getLogger(__name__) _SENTINEL = object() DATE_STR_FORMAT = "%Y-%m-%d %H:%M:%S" -_RENDER_INFO = "template.render_info" _ENVIRONMENT = "template.environment" _ENVIRONMENT_LIMITED = "template.environment_limited" _ENVIRONMENT_STRICT = "template.environment_strict" @@ -122,6 +121,9 @@ _P = ParamSpec("_P") ALL_STATES_RATE_LIMIT = timedelta(minutes=1) DOMAIN_STATES_RATE_LIMIT = timedelta(seconds=1) +_render_info: ContextVar[RenderInfo | None] = ContextVar("_render_info", default=None) + + template_cv: ContextVar[tuple[str, str] | None] = ContextVar( "template_cv", default=None ) @@ -651,7 +653,7 @@ class Template: ) -> RenderInfo: """Render the template and collect an entity filter.""" self._renders += 1 - assert self.hass and _RENDER_INFO not in self.hass.data + assert self.hass and _render_info.get() is None render_info = RenderInfo(self) @@ -661,13 +663,13 @@ class Template: render_info._freeze_static() return render_info - self.hass.data[_RENDER_INFO] = render_info + token = _render_info.set(render_info) try: render_info._result = self.async_render(variables, strict=strict, **kwargs) except TemplateError as ex: render_info.exception = ex finally: - del self.hass.data[_RENDER_INFO] + _render_info.reset(token) render_info._freeze() return render_info @@ -807,13 +809,11 @@ class AllStates: __getitem__ = __getattr__ def _collect_all(self) -> None: - render_info = self._hass.data.get(_RENDER_INFO) - if render_info is not None: + if (render_info := _render_info.get()) is not None: render_info.all_states = True def _collect_all_lifecycle(self) -> None: - render_info = self._hass.data.get(_RENDER_INFO) - if render_info is not None: + if (render_info := _render_info.get()) is not None: render_info.all_states_lifecycle = True def __iter__(self) -> Generator[TemplateState, None, None]: @@ -869,14 +869,12 @@ class DomainStates: __getitem__ = __getattr__ def _collect_domain(self) -> None: - entity_collect = self._hass.data.get(_RENDER_INFO) - if entity_collect is not None: - entity_collect.domains.add(self._domain) + if (entity_collect := _render_info.get()) is not None: + entity_collect.domains.add(self._domain) # type: ignore[attr-defined] def _collect_domain_lifecycle(self) -> None: - entity_collect = self._hass.data.get(_RENDER_INFO) - if entity_collect is not None: - entity_collect.domains_lifecycle.add(self._domain) + if (entity_collect := _render_info.get()) is not None: + entity_collect.domains_lifecycle.add(self._domain) # type: ignore[attr-defined] def __iter__(self) -> Generator[TemplateState, None, None]: """Return the iteration over all the states.""" @@ -913,8 +911,8 @@ class TemplateStateBase(State): self._as_dict: ReadOnlyDict[str, Collection[Any]] | None = None def _collect_state(self) -> None: - if self._collect and (_render_info := self._hass.data.get(_RENDER_INFO)): - _render_info.entities.add(self._entity_id) + if self._collect and (render_info := _render_info.get()): + render_info.entities.add(self._entity_id) # type: ignore[attr-defined] # Jinja will try __getitem__ first and it avoids the need # to call is_safe_attribute @@ -922,8 +920,8 @@ class TemplateStateBase(State): """Return a property as an attribute for jinja.""" if item in _COLLECTABLE_STATE_ATTRIBUTES: # _collect_state inlined here for performance - if self._collect and (_render_info := self._hass.data.get(_RENDER_INFO)): - _render_info.entities.add(self._entity_id) + if self._collect and (render_info := _render_info.get()): + render_info.entities.add(self._entity_id) # type: ignore[attr-defined] return getattr(self._state, item) if item == "entity_id": return self._entity_id @@ -1057,8 +1055,8 @@ _create_template_state_no_collect = partial(TemplateState, collect=False) def _collect_state(hass: HomeAssistant, entity_id: str) -> None: - if (entity_collect := hass.data.get(_RENDER_INFO)) is not None: - entity_collect.entities.add(entity_id) + if (entity_collect := _render_info.get()) is not None: + entity_collect.entities.add(entity_id) # type: ignore[attr-defined] def _state_generator( @@ -1575,7 +1573,7 @@ def has_value(hass: HomeAssistant, entity_id: str) -> bool: def now(hass: HomeAssistant) -> datetime: """Record fetching now.""" - if (render_info := hass.data.get(_RENDER_INFO)) is not None: + if (render_info := _render_info.get()) is not None: render_info.has_time = True return dt_util.now() @@ -1583,7 +1581,7 @@ def now(hass: HomeAssistant) -> datetime: def utcnow(hass: HomeAssistant) -> datetime: """Record fetching utcnow.""" - if (render_info := hass.data.get(_RENDER_INFO)) is not None: + if (render_info := _render_info.get()) is not None: render_info.has_time = True return dt_util.utcnow() @@ -2081,7 +2079,7 @@ def random_every_time(context, values): def today_at(hass: HomeAssistant, time_str: str = "") -> datetime: """Record fetching now where the time has been replaced with value.""" - if (render_info := hass.data.get(_RENDER_INFO)) is not None: + if (render_info := _render_info.get()) is not None: render_info.has_time = True today = dt_util.start_of_local_day() @@ -2106,7 +2104,7 @@ def relative_time(hass: HomeAssistant, value: Any) -> Any: If the input are not a datetime object the input will be returned unmodified. """ - if (render_info := hass.data.get(_RENDER_INFO)) is not None: + if (render_info := _render_info.get()) is not None: render_info.has_time = True if not isinstance(value, datetime): From b95405a7e9347d2538a8a7b47835508d44665584 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 May 2023 11:28:34 -0500 Subject: [PATCH 348/822] Small performance improvements to state diff messages (#92963) Adds missing test coverage --- .../components/websocket_api/messages.py | 35 +++-- .../components/websocket_api/test_messages.py | 146 +++++++++++++++++- 2 files changed, 164 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/websocket_api/messages.py b/homeassistant/components/websocket_api/messages.py index ec1ab267a37b..341aebdf9e49 100644 --- a/homeassistant/components/websocket_api/messages.py +++ b/homeassistant/components/websocket_api/messages.py @@ -3,7 +3,7 @@ from __future__ import annotations from functools import lru_cache import logging -from typing import Any, Final +from typing import TYPE_CHECKING, Any, Final, cast import voluptuous as vol @@ -121,14 +121,16 @@ def _state_diff_event(event: Event) -> dict: """ if (event_new_state := event.data["new_state"]) is None: return {ENTITY_EVENT_REMOVE: [event.data["entity_id"]]} - assert isinstance(event_new_state, State) + if TYPE_CHECKING: + event_new_state = cast(State, event_new_state) 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() } } - assert isinstance(event_old_state, State) + if TYPE_CHECKING: + event_old_state = cast(State, event_old_state) return _state_diff(event_old_state, event_new_state) @@ -136,27 +138,28 @@ def _state_diff( old_state: State, new_state: State ) -> dict[str, dict[str, dict[str, dict[str, str | list[str]]]]]: """Create a diff dict that can be used to overlay changes.""" - diff: dict = {STATE_DIFF_ADDITIONS: {}} - additions = diff[STATE_DIFF_ADDITIONS] + additions: dict[str, Any] = {} + diff: dict[str, dict[str, Any]] = {STATE_DIFF_ADDITIONS: additions} + new_state_context = new_state.context + old_state_context = old_state.context if old_state.state != new_state.state: additions[COMPRESSED_STATE_STATE] = new_state.state if old_state.last_changed != new_state.last_changed: additions[COMPRESSED_STATE_LAST_CHANGED] = new_state.last_changed.timestamp() elif old_state.last_updated != new_state.last_updated: additions[COMPRESSED_STATE_LAST_UPDATED] = new_state.last_updated.timestamp() - if old_state.context.parent_id != new_state.context.parent_id: - additions.setdefault(COMPRESSED_STATE_CONTEXT, {})[ - "parent_id" - ] = new_state.context.parent_id - if old_state.context.user_id != new_state.context.user_id: - additions.setdefault(COMPRESSED_STATE_CONTEXT, {})[ - "user_id" - ] = new_state.context.user_id - if old_state.context.id != new_state.context.id: + if old_state_context.parent_id != new_state_context.parent_id: + additions[COMPRESSED_STATE_CONTEXT] = {"parent_id": new_state_context.parent_id} + if old_state_context.user_id != new_state_context.user_id: if COMPRESSED_STATE_CONTEXT in additions: - additions[COMPRESSED_STATE_CONTEXT]["id"] = new_state.context.id + additions[COMPRESSED_STATE_CONTEXT]["user_id"] = new_state_context.user_id else: - additions[COMPRESSED_STATE_CONTEXT] = new_state.context.id + additions[COMPRESSED_STATE_CONTEXT] = {"user_id": new_state_context.user_id} + if old_state_context.id != new_state_context.id: + if COMPRESSED_STATE_CONTEXT in additions: + additions[COMPRESSED_STATE_CONTEXT]["id"] = new_state_context.id + else: + additions[COMPRESSED_STATE_CONTEXT] = new_state_context.id if (old_attributes := old_state.attributes) != ( new_attributes := new_state.attributes ): diff --git a/tests/components/websocket_api/test_messages.py b/tests/components/websocket_api/test_messages.py index ec7523a02e5e..d2102b651b7f 100644 --- a/tests/components/websocket_api/test_messages.py +++ b/tests/components/websocket_api/test_messages.py @@ -3,11 +3,14 @@ import pytest from homeassistant.components.websocket_api.messages import ( _cached_event_message as lru_event_cache, + _state_diff_event, cached_event_message, message_to_json, ) from homeassistant.const import EVENT_STATE_CHANGED -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import Context, Event, HomeAssistant, State, callback + +from tests.common import async_capture_events async def test_cached_event_message(hass: HomeAssistant) -> None: @@ -79,6 +82,147 @@ async def test_cached_event_message_with_different_idens(hass: HomeAssistant) -> assert cache_info.currsize == 1 +async def test_state_diff_event(hass: HomeAssistant) -> None: + """Test building state_diff_message.""" + state_change_events = async_capture_events(hass, EVENT_STATE_CHANGED) + context = Context(user_id="user-id", parent_id="parent-id", id="id") + hass.states.async_set("light.window", "on", context=context) + hass.states.async_set("light.window", "off", context=context) + await hass.async_block_till_done() + + last_state_event: Event = state_change_events[-1] + new_state: State = last_state_event.data["new_state"] + message = _state_diff_event(last_state_event) + assert message == { + "c": { + "light.window": { + "+": {"lc": new_state.last_changed.timestamp(), "s": "off"} + } + } + } + + hass.states.async_set( + "light.window", + "red", + context=Context(user_id="user-id", parent_id="new-parent-id", id="id"), + ) + await hass.async_block_till_done() + last_state_event: Event = state_change_events[-1] + new_state: State = last_state_event.data["new_state"] + message = _state_diff_event(last_state_event) + + assert message == { + "c": { + "light.window": { + "+": { + "c": {"parent_id": "new-parent-id"}, + "lc": new_state.last_changed.timestamp(), + "s": "red", + } + } + } + } + + hass.states.async_set( + "light.window", + "green", + context=Context( + user_id="new-user-id", parent_id="another-new-parent-id", id="id" + ), + ) + await hass.async_block_till_done() + last_state_event: Event = state_change_events[-1] + new_state: State = last_state_event.data["new_state"] + message = _state_diff_event(last_state_event) + + assert message == { + "c": { + "light.window": { + "+": { + "c": { + "parent_id": "another-new-parent-id", + "user_id": "new-user-id", + }, + "lc": new_state.last_changed.timestamp(), + "s": "green", + } + } + } + } + + hass.states.async_set( + "light.window", + "blue", + context=Context( + user_id="another-new-user-id", parent_id="another-new-parent-id", id="id" + ), + ) + await hass.async_block_till_done() + last_state_event: Event = state_change_events[-1] + new_state: State = last_state_event.data["new_state"] + message = _state_diff_event(last_state_event) + + assert message == { + "c": { + "light.window": { + "+": { + "c": {"user_id": "another-new-user-id"}, + "lc": new_state.last_changed.timestamp(), + "s": "blue", + } + } + } + } + + hass.states.async_set( + "light.window", + "yellow", + context=Context( + user_id="another-new-user-id", + parent_id="another-new-parent-id", + id="id-new", + ), + ) + await hass.async_block_till_done() + last_state_event: Event = state_change_events[-1] + new_state: State = last_state_event.data["new_state"] + message = _state_diff_event(last_state_event) + + assert message == { + "c": { + "light.window": { + "+": { + "c": "id-new", + "lc": new_state.last_changed.timestamp(), + "s": "yellow", + } + } + } + } + + new_context = Context() + hass.states.async_set( + "light.window", "purple", {"new": "attr"}, context=new_context + ) + await hass.async_block_till_done() + last_state_event: Event = state_change_events[-1] + new_state: State = last_state_event.data["new_state"] + message = _state_diff_event(last_state_event) + + assert message == { + "c": { + "light.window": { + "+": { + "a": {"new": "attr"}, + "c": {"id": new_context.id, "parent_id": None, "user_id": None}, + "lc": new_state.last_changed.timestamp(), + "s": "purple", + } + } + } + } + + async def test_message_to_json(caplog: pytest.LogCaptureFixture) -> None: """Test we can serialize websocket messages.""" From d5a0824924ae2c3f4a7565b21b574abc82288e80 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 May 2023 14:42:04 -0500 Subject: [PATCH 349/822] Use futures instead of asyncio.Event for async_get_integrations (#93060) Co-authored-by: Paulus Schoutsen --- homeassistant/loader.py | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 963ddcf48fc4..e7658c44567c 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -889,39 +889,41 @@ async def async_get_integrations( cache = hass.data[DATA_INTEGRATIONS] = {} results: dict[str, Integration | Exception] = {} - needed: dict[str, asyncio.Event] = {} - in_progress: dict[str, asyncio.Event] = {} + needed: dict[str, asyncio.Future[None]] = {} + in_progress: dict[str, asyncio.Future[None]] = {} for domain in domains: - int_or_evt: Integration | asyncio.Event | None = cache.get(domain, _UNDEF) - if isinstance(int_or_evt, asyncio.Event): - in_progress[domain] = int_or_evt - elif int_or_evt is not _UNDEF: - results[domain] = cast(Integration, int_or_evt) + int_or_fut: Integration | asyncio.Future[None] | None = cache.get( + domain, _UNDEF + ) + if isinstance(int_or_fut, asyncio.Future): + in_progress[domain] = int_or_fut + elif int_or_fut is not _UNDEF: + results[domain] = cast(Integration, int_or_fut) elif "." in domain: results[domain] = ValueError(f"Invalid domain {domain}") else: - needed[domain] = cache[domain] = asyncio.Event() + needed[domain] = cache[domain] = hass.loop.create_future() if in_progress: - await asyncio.gather(*[event.wait() for event in in_progress.values()]) + await asyncio.gather(*in_progress.values()) for domain in in_progress: # When we have waited and it's _UNDEF, it doesn't exist # We don't cache that it doesn't exist, or else people can't fix it # and then restart, because their config will never be valid. - if (int_or_evt := cache.get(domain, _UNDEF)) is _UNDEF: + if (int_or_fut := cache.get(domain, _UNDEF)) is _UNDEF: results[domain] = IntegrationNotFound(domain) else: - results[domain] = cast(Integration, int_or_evt) + results[domain] = cast(Integration, int_or_fut) # First we look for custom components if needed: # Instead of using resolve_from_root we use the cache of custom # components to find the integration. custom = await async_get_custom_components(hass) - for domain, event in needed.items(): + for domain, future in needed.items(): if integration := custom.get(domain): results[domain] = cache[domain] = integration - event.set() + future.set_result(None) for domain in results: if domain in needed: @@ -934,7 +936,7 @@ async def async_get_integrations( integrations = await hass.async_add_executor_job( _resolve_integrations_from_root, hass, components, list(needed) ) - for domain, event in needed.items(): + for domain, future in needed.items(): int_or_exc = integrations.get(domain) if not int_or_exc: cache.pop(domain) @@ -946,7 +948,7 @@ async def async_get_integrations( results[domain] = exc else: results[domain] = cache[domain] = int_or_exc - event.set() + future.set_result(None) return results From ef887e529fd33c7fbf32e19b133072cb8ca466d6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 May 2023 15:08:39 -0500 Subject: [PATCH 350/822] Fix onvif cameras that cannot parse relative time (#92711) * Fix onvif cameras that cannot parse relative time The spec requires that the camera can parse relative or absolute timestamps However there are many cameras that cannot parse time correctly. Much of the event code has been offloaded to the library and support to determine if the camera has broken time and switch to absolute timestamps is now built into the library * adjust verison * fixes * bump * bump * bump * more fixes * preen * fix resume * one more fix * fix race in webhook setup * bump to 3.1.3 which has more fixes for broken camera firmwares * bump 3.1.4 for more fixes * fix * fix comment * bump * fix url limit * bump * more fixes * old hik uses -s --- homeassistant/components/onvif/__init__.py | 2 +- homeassistant/components/onvif/config_flow.py | 2 +- homeassistant/components/onvif/device.py | 6 +- homeassistant/components/onvif/event.py | 589 ++++++------------ homeassistant/components/onvif/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 199 insertions(+), 406 deletions(-) diff --git a/homeassistant/components/onvif/__init__.py b/homeassistant/components/onvif/__init__.py index a7c23064f645..36b4a28dffd4 100644 --- a/homeassistant/components/onvif/__init__.py +++ b/homeassistant/components/onvif/__init__.py @@ -5,6 +5,7 @@ import logging from httpx import RequestError from onvif.exceptions import ONVIFAuthError, ONVIFError, ONVIFTimeoutError +from onvif.util import is_auth_error, stringify_onvif_error from zeep.exceptions import Fault, TransportError from homeassistant.components.ffmpeg import CONF_EXTRA_ARGUMENTS @@ -21,7 +22,6 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from .const import CONF_SNAPSHOT_AUTH, DEFAULT_ARGUMENTS, DOMAIN from .device import ONVIFDevice -from .util import is_auth_error, stringify_onvif_error LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/onvif/config_flow.py b/homeassistant/components/onvif/config_flow.py index ca447c71b844..da948787e495 100644 --- a/homeassistant/components/onvif/config_flow.py +++ b/homeassistant/components/onvif/config_flow.py @@ -6,6 +6,7 @@ from pprint import pformat from typing import Any from urllib.parse import urlparse +from onvif.util import is_auth_error, stringify_onvif_error import voluptuous as vol from wsdiscovery.discovery import ThreadedWSDiscovery as WSDiscovery from wsdiscovery.scope import Scope @@ -40,7 +41,6 @@ from .const import ( LOGGER, ) from .device import get_device -from .util import is_auth_error, stringify_onvif_error CONF_MANUAL_INPUT = "Manually configure ONVIF device" diff --git a/homeassistant/components/onvif/device.py b/homeassistant/components/onvif/device.py index ea2325f271c7..1152503a718f 100644 --- a/homeassistant/components/onvif/device.py +++ b/homeassistant/components/onvif/device.py @@ -195,7 +195,9 @@ class ONVIFDevice: await device_mgmt.SetSystemDateAndTime(dt_param) LOGGER.debug("%s: SetSystemDateAndTime: success", self.name) return - except Fault: + # Some cameras don't support setting the timezone and will throw an IndexError + # if we try to set it. If we get an error, try again without the timezone. + except (IndexError, Fault): if idx == timezone_max_idx: raise @@ -280,7 +282,7 @@ class ONVIFDevice: # Set Date and Time ourselves if Date and Time is set manually in the camera. try: await self.async_manually_set_date_and_time() - except (RequestError, TransportError): + except (RequestError, TransportError, IndexError, Fault): LOGGER.warning("%s: Could not sync date/time on this camera", self.name) async def async_get_device_info(self) -> DeviceInfo: diff --git a/homeassistant/components/onvif/event.py b/homeassistant/components/onvif/event.py index 507eda600971..dbff9660b12d 100644 --- a/homeassistant/components/onvif/event.py +++ b/homeassistant/components/onvif/event.py @@ -3,32 +3,30 @@ from __future__ import annotations import asyncio from collections.abc import Callable -from contextlib import suppress import datetime as dt from aiohttp.web import Request from httpx import RemoteProtocolError, RequestError, TransportError from onvif import ONVIFCamera, ONVIFService -from onvif.client import NotificationManager, retry_connection_error +from onvif.client import ( + NotificationManager, + PullPointManager as ONVIFPullPointManager, + retry_connection_error, +) from onvif.exceptions import ONVIFError +from onvif.util import stringify_onvif_error from zeep.exceptions import Fault, ValidationError, XMLParseError from homeassistant.components import webhook from homeassistant.config_entries import ConfigEntry -from homeassistant.core import ( - CALLBACK_TYPE, - CoreState, - HassJob, - HomeAssistant, - callback, -) +from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback +from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.event import async_call_later from homeassistant.helpers.network import NoURLAvailableError, get_url from .const import DOMAIN, LOGGER from .models import Event, PullPointManagerState, WebHookManagerState from .parsers import PARSERS -from .util import stringify_onvif_error # Topics in this list are ignored because we do not want to create # entities for them. @@ -51,11 +49,6 @@ RENEW_ERRORS = (ONVIFError, RequestError, XMLParseError, *SUBSCRIPTION_ERRORS) # SUBSCRIPTION_TIME = dt.timedelta(minutes=10) -# SUBSCRIPTION_RELATIVE_TIME uses a relative time since the time on the camera -# is not reliable. We use 600 seconds (10 minutes) since some cameras cannot -# parse time in the format "PT10M" (10 minutes). -SUBSCRIPTION_RELATIVE_TIME = "PT600S" - # SUBSCRIPTION_RENEW_INTERVAL Must be less than the # overall timeout of 90 * (SUBSCRIPTION_ATTEMPTS) 2 = 180 seconds # @@ -106,18 +99,13 @@ class EventManager: or self.pullpoint_manager.state == PullPointManagerState.STARTED ) - @property - def has_listeners(self) -> bool: - """Return if there are listeners.""" - return bool(self._listeners) - @callback def async_add_listener(self, update_callback: CALLBACK_TYPE) -> Callable[[], None]: """Listen for data updates.""" - # This is the first listener, set up polling. - if not self._listeners: - self.pullpoint_manager.async_schedule_pull_messages() - + # We always have to listen for events or we will never + # know which sensors to create. In practice we always have + # a listener anyways since binary_sensor and sensor will + # create a listener when they are created. self._listeners.append(update_callback) @callback @@ -133,9 +121,6 @@ class EventManager: if update_callback in self._listeners: self._listeners.remove(update_callback) - if not self._listeners: - self.pullpoint_manager.async_cancel_pull_messages() - async def async_start(self, try_pullpoint: bool, try_webhook: bool) -> bool: """Start polling events.""" # Always start pull point first, since it will populate the event list @@ -255,22 +240,15 @@ class PullPointManager: self._hass = event_manager.hass self._name = event_manager.name - self._pullpoint_subscription: ONVIFService = None self._pullpoint_service: ONVIFService = None - self._pull_lock: asyncio.Lock = asyncio.Lock() + self._pullpoint_manager: ONVIFPullPointManager | None = None self._cancel_pull_messages: CALLBACK_TYPE | None = None - self._cancel_pullpoint_renew: CALLBACK_TYPE | None = None - - self._renew_lock: asyncio.Lock = asyncio.Lock() - self._renew_or_restart_job = HassJob( - self._async_renew_or_restart_pullpoint, - f"{self._name}: renew or restart pullpoint", - ) self._pull_messages_job = HassJob( - self._async_background_pull_messages, + self._async_background_pull_messages_or_reschedule, f"{self._name}: pull messages", ) + self._pull_messages_task: asyncio.Task[None] | None = None async def async_start(self) -> bool: """Start pullpoint subscription.""" @@ -282,6 +260,7 @@ class PullPointManager: self.state = PullPointManagerState.FAILED return False self.state = PullPointManagerState.STARTED + self.async_schedule_pull_messages() return True @callback @@ -291,8 +270,9 @@ class PullPointManager: self.state = PullPointManagerState.PAUSED # Cancel the renew job so we don't renew the subscription # and stop pulling messages. - self._async_cancel_pullpoint_renew() self.async_cancel_pull_messages() + if self._pullpoint_manager: + self._pullpoint_manager.pause() # We do not unsubscribe from the pullpoint subscription and instead # let the subscription expire since some cameras will terminate all # subscriptions if we unsubscribe which will break the webhook. @@ -302,17 +282,150 @@ class PullPointManager: """Resume pullpoint subscription.""" LOGGER.debug("%s: Resuming PullPoint manager", self._name) self.state = PullPointManagerState.STARTED - self.async_schedule_pullpoint_renew(0.0) + if self._pullpoint_manager: + self._pullpoint_manager.resume() + self.async_schedule_pull_messages() - @callback - def async_schedule_pullpoint_renew(self, delay: float) -> None: - """Schedule PullPoint subscription renewal.""" - self._async_cancel_pullpoint_renew() - self._cancel_pullpoint_renew = async_call_later( - self._hass, - delay, - self._renew_or_restart_job, + async def async_stop(self) -> None: + """Unsubscribe from PullPoint and cancel callbacks.""" + self.state = PullPointManagerState.STOPPED + await self._async_cancel_and_unsubscribe() + + async def _async_start_pullpoint(self) -> bool: + """Start pullpoint subscription.""" + try: + await self._async_create_pullpoint_subscription() + except CREATE_ERRORS as err: + LOGGER.debug( + "%s: Device does not support PullPoint service or has too many subscriptions: %s", + self._name, + stringify_onvif_error(err), + ) + return False + return True + + async def _async_cancel_and_unsubscribe(self) -> None: + """Cancel and unsubscribe from PullPoint.""" + self.async_cancel_pull_messages() + if self._pull_messages_task: + self._pull_messages_task.cancel() + await self._async_unsubscribe_pullpoint() + + @retry_connection_error(SUBSCRIPTION_ATTEMPTS) + async def _async_create_pullpoint_subscription(self) -> None: + """Create pullpoint subscription.""" + self._pullpoint_manager = await self._device.create_pullpoint_manager( + SUBSCRIPTION_TIME, self._event_manager.async_mark_events_stale ) + self._pullpoint_service = self._pullpoint_manager.get_service() + await self._pullpoint_manager.set_synchronization_point() + + async def _async_unsubscribe_pullpoint(self) -> None: + """Unsubscribe the pullpoint subscription.""" + if not self._pullpoint_manager or self._pullpoint_manager.closed: + return + LOGGER.debug("%s: Unsubscribing from PullPoint", self._name) + try: + await self._pullpoint_manager.shutdown() + except UNSUBSCRIBE_ERRORS as err: + LOGGER.debug( + ( + "%s: Failed to unsubscribe PullPoint subscription;" + " This is normal if the device restarted: %s" + ), + self._name, + stringify_onvif_error(err), + ) + self._pullpoint_manager = None + + async def _async_pull_messages(self) -> None: + """Pull messages from device.""" + if self._pullpoint_manager is None: + return + assert self._pullpoint_service is not None, "PullPoint service does not exist" + LOGGER.debug( + "%s: Pulling PullPoint messages timeout=%s limit=%s", + self._name, + PULLPOINT_POLL_TIME, + PULLPOINT_MESSAGE_LIMIT, + ) + next_pull_delay = None + response = None + try: + if self._hass.is_running: + response = await self._pullpoint_service.PullMessages( + { + "MessageLimit": PULLPOINT_MESSAGE_LIMIT, + "Timeout": PULLPOINT_POLL_TIME, + } + ) + else: + LOGGER.debug( + "%s: PullPoint skipped because Home Assistant is not running yet", + self._name, + ) + except RemoteProtocolError as err: + # Either a shutdown event or the camera closed the connection. Because + # http://datatracker.ietf.org/doc/html/rfc2616#section-8.1.4 allows the server + # to close the connection at any time, we treat this as a normal. Some + # cameras may close the connection if there are no messages to pull. + LOGGER.debug( + "%s: PullPoint subscription encountered a remote protocol error " + "(this is normal for some cameras): %s", + self._name, + stringify_onvif_error(err), + ) + except Fault as err: + # Device may not support subscriptions so log at debug level + # when we get an XMLParseError + LOGGER.debug( + "%s: Failed to fetch PullPoint subscription messages: %s", + self._name, + stringify_onvif_error(err), + ) + # Treat errors as if the camera restarted. Assume that the pullpoint + # subscription is no longer valid. + self._pullpoint_manager.resume() + except (XMLParseError, RequestError, TimeoutError, TransportError) as err: + LOGGER.debug( + "%s: PullPoint subscription encountered an unexpected error and will be retried " + "(this is normal for some cameras): %s", + self._name, + stringify_onvif_error(err), + ) + # Avoid renewing the subscription too often since it causes problems + # for some cameras, mainly the Tapo ones. + next_pull_delay = SUBSCRIPTION_RESTART_INTERVAL_ON_ERROR + finally: + self.async_schedule_pull_messages(next_pull_delay) + + if self.state != PullPointManagerState.STARTED: + # If the webhook became started working during the long poll, + # and we got paused, our data is stale and we should not process it. + LOGGER.debug( + "%s: PullPoint state is %s (likely due to working webhook), skipping PullPoint messages", + self._name, + self.state, + ) + return + + if not response: + return + + # Parse response + event_manager = self._event_manager + if (notification_message := response.NotificationMessage) and ( + number_of_events := len(notification_message) + ): + LOGGER.debug( + "%s: continuous PullMessages: %s event(s)", + self._name, + number_of_events, + ) + await event_manager.async_parse_messages(notification_message) + event_manager.async_callback_listeners() + else: + LOGGER.debug("%s: continuous PullMessages: no events", self._name) @callback def async_cancel_pull_messages(self) -> None: @@ -338,269 +451,23 @@ class PullPointManager: self._hass, when, self._pull_messages_job ) - async def async_stop(self) -> None: - """Unsubscribe from PullPoint and cancel callbacks.""" - self.state = PullPointManagerState.STOPPED - await self._async_cancel_and_unsubscribe() - - async def _async_start_pullpoint(self) -> bool: - """Start pullpoint subscription.""" - try: - started = await self._async_create_pullpoint_subscription() - except CREATE_ERRORS as err: - LOGGER.debug( - "%s: Device does not support PullPoint service or has too many subscriptions: %s", - self._name, - stringify_onvif_error(err), - ) - return False - - if started: - self.async_schedule_pullpoint_renew(SUBSCRIPTION_RENEW_INTERVAL) - - return started - - async def _async_cancel_and_unsubscribe(self) -> None: - """Cancel and unsubscribe from PullPoint.""" - self._async_cancel_pullpoint_renew() - self.async_cancel_pull_messages() - await self._async_unsubscribe_pullpoint() - - async def _async_renew_or_restart_pullpoint( - self, now: dt.datetime | None = None + @callback + def _async_background_pull_messages_or_reschedule( + self, _now: dt.datetime | None = None ) -> None: - """Renew or start pullpoint subscription.""" - if self._hass.is_stopping or self.state != PullPointManagerState.STARTED: - return - if self._renew_lock.locked(): - LOGGER.debug("%s: PullPoint renew already in progress", self._name) - # Renew is already running, another one will be - # scheduled when the current one is done if needed. - return - async with self._renew_lock: - next_attempt = SUBSCRIPTION_RESTART_INTERVAL_ON_ERROR - try: - if await self._async_renew_pullpoint(): - next_attempt = SUBSCRIPTION_RENEW_INTERVAL - else: - await self._async_restart_pullpoint() - finally: - self.async_schedule_pullpoint_renew(next_attempt) - - @retry_connection_error(SUBSCRIPTION_ATTEMPTS) - async def _async_create_pullpoint_subscription(self) -> bool: - """Create pullpoint subscription.""" - - if not await self._device.create_pullpoint_subscription( - {"InitialTerminationTime": SUBSCRIPTION_RELATIVE_TIME} - ): - LOGGER.debug("%s: Failed to create PullPoint subscription", self._name) - return False - - # Create subscription manager - self._pullpoint_subscription = await self._device.create_subscription_service( - "PullPointSubscription" - ) - - # Create the service that will be used to pull messages from the device. - self._pullpoint_service = await self._device.create_pullpoint_service() - - # Initialize events - with suppress(*SET_SYNCHRONIZATION_POINT_ERRORS): - sync_result = await self._pullpoint_service.SetSynchronizationPoint() - LOGGER.debug("%s: SetSynchronizationPoint: %s", self._name, sync_result) - - # Always schedule an initial pull messages - self.async_schedule_pull_messages(0.0) - - return True - - @callback - def _async_cancel_pullpoint_renew(self) -> None: - """Cancel the pullpoint renew task.""" - if self._cancel_pullpoint_renew: - self._cancel_pullpoint_renew() - self._cancel_pullpoint_renew = None - - async def _async_restart_pullpoint(self) -> bool: - """Restart the subscription assuming the camera rebooted.""" - self.async_cancel_pull_messages() - await self._async_unsubscribe_pullpoint() - restarted = await self._async_start_pullpoint() - if restarted and self._event_manager.has_listeners: - LOGGER.debug("%s: Restarted PullPoint subscription", self._name) - self.async_schedule_pull_messages(0.0) - return restarted - - async def _async_unsubscribe_pullpoint(self) -> None: - """Unsubscribe the pullpoint subscription.""" - if ( - not self._pullpoint_subscription - or self._pullpoint_subscription.transport.client.is_closed - ): - return - LOGGER.debug("%s: Unsubscribing from PullPoint", self._name) - try: - await self._pullpoint_subscription.Unsubscribe() - except UNSUBSCRIBE_ERRORS as err: - LOGGER.debug( - ( - "%s: Failed to unsubscribe PullPoint subscription;" - " This is normal if the device restarted: %s" - ), - self._name, - stringify_onvif_error(err), - ) - self._pullpoint_subscription = None - - @retry_connection_error(SUBSCRIPTION_ATTEMPTS) - async def _async_call_pullpoint_subscription_renew(self) -> None: - """Call PullPoint subscription Renew.""" - await self._pullpoint_subscription.Renew(SUBSCRIPTION_RELATIVE_TIME) - - async def _async_renew_pullpoint(self) -> bool: - """Renew the PullPoint subscription.""" - if ( - not self._pullpoint_subscription - or self._pullpoint_subscription.transport.client.is_closed - ): - return False - try: - # The first time we renew, we may get a Fault error so we - # suppress it. The subscription will be restarted in - # async_restart later. - await self._async_call_pullpoint_subscription_renew() - LOGGER.debug("%s: Renewed PullPoint subscription", self._name) - return True - except RENEW_ERRORS as err: - self._event_manager.async_mark_events_stale() - LOGGER.debug( - "%s: Failed to renew PullPoint subscription; %s", - self._name, - stringify_onvif_error(err), - ) - return False - - async def _async_pull_messages_with_lock(self) -> bool: - """Pull messages from device while holding the lock. - - This function must not be called directly, it should only - be called from _async_pull_messages. - - Returns True if the subscription is working. - - Returns False if the subscription is not working and should be restarted. - """ - assert self._pull_lock.locked(), "Pull lock must be held" - assert self._pullpoint_service is not None, "PullPoint service does not exist" - event_manager = self._event_manager - LOGGER.debug( - "%s: Pulling PullPoint messages timeout=%s limit=%s", - self._name, - PULLPOINT_POLL_TIME, - PULLPOINT_MESSAGE_LIMIT, - ) - try: - response = await self._pullpoint_service.PullMessages( - { - "MessageLimit": PULLPOINT_MESSAGE_LIMIT, - "Timeout": PULLPOINT_POLL_TIME, - } - ) - except RemoteProtocolError as err: - # Either a shutdown event or the camera closed the connection. Because - # http://datatracker.ietf.org/doc/html/rfc2616#section-8.1.4 allows the server - # to close the connection at any time, we treat this as a normal. Some - # cameras may close the connection if there are no messages to pull. - LOGGER.debug( - "%s: PullPoint subscription encountered a remote protocol error " - "(this is normal for some cameras): %s", - self._name, - stringify_onvif_error(err), - ) - return True - except Fault as err: - # Device may not support subscriptions so log at debug level - # when we get an XMLParseError - LOGGER.debug( - "%s: Failed to fetch PullPoint subscription messages: %s", - self._name, - stringify_onvif_error(err), - ) - # Treat errors as if the camera restarted. Assume that the pullpoint - # subscription is no longer valid. - return False - except (XMLParseError, RequestError, TimeoutError, TransportError) as err: - LOGGER.debug( - "%s: PullPoint subscription encountered an unexpected error and will be retried " - "(this is normal for some cameras): %s", - self._name, - stringify_onvif_error(err), - ) - # Avoid renewing the subscription too often since it causes problems - # for some cameras, mainly the Tapo ones. - return True - - if self.state != PullPointManagerState.STARTED: - # If the webhook became started working during the long poll, - # and we got paused, our data is stale and we should not process it. - LOGGER.debug( - "%s: PullPoint is paused (likely due to working webhook), skipping PullPoint messages", - self._name, - ) - return True - - # Parse response - if (notification_message := response.NotificationMessage) and ( - number_of_events := len(notification_message) - ): - LOGGER.debug( - "%s: continuous PullMessages: %s event(s)", - self._name, - number_of_events, - ) - await event_manager.async_parse_messages(notification_message) - event_manager.async_callback_listeners() - else: - LOGGER.debug("%s: continuous PullMessages: no events", self._name) - - return True - - @callback - def _async_background_pull_messages(self, _now: dt.datetime | None = None) -> None: """Pull messages from device in the background.""" - self._cancel_pull_messages = None - self._hass.async_create_background_task( + if self._pull_messages_task and not self._pull_messages_task.done(): + LOGGER.debug( + "%s: PullPoint message pull is already in process, skipping pull", + self._name, + ) + self.async_schedule_pull_messages() + return + self._pull_messages_task = self._hass.async_create_background_task( self._async_pull_messages(), f"{self._name} background pull messages", ) - async def _async_pull_messages(self) -> None: - """Pull messages from device.""" - event_manager = self._event_manager - - if self._pull_lock.locked(): - # Pull messages if the lock is not already locked - # any pull will do, so we don't need to wait for the lock - LOGGER.debug( - "%s: PullPoint subscription is already locked, skipping pull", - self._name, - ) - return - - async with self._pull_lock: - # Before we pop out of the lock we always need to schedule the next pull - # or call async_schedule_pullpoint_renew if the pull fails so the pull - # loop continues. - try: - if self._hass.state == CoreState.running: - if not await self._async_pull_messages_with_lock(): - self.async_schedule_pullpoint_renew(0.0) - return - finally: - if event_manager.has_listeners: - self.async_schedule_pull_messages() - class WebHookManager: """Manage ONVIF webhook subscriptions. @@ -617,21 +484,21 @@ class WebHookManager: self._event_manager = event_manager self._device = event_manager.device self._hass = event_manager.hass - self._webhook_unique_id = f"{DOMAIN}_{event_manager.config_entry.entry_id}" + config_entry = event_manager.config_entry + + self._old_webhook_unique_id = f"{DOMAIN}_{config_entry.entry_id}" + # Some cameras have a limit on the length of the webhook URL + # so we use a shorter unique ID for the webhook. + unique_id = config_entry.unique_id + assert unique_id is not None + webhook_id = format_mac(unique_id).replace(":", "").lower() + self._webhook_unique_id = f"{DOMAIN}{webhook_id}" self._name = event_manager.name self._webhook_url: str | None = None - self._webhook_subscription: ONVIFService | None = None self._notification_manager: NotificationManager | None = None - self._cancel_webhook_renew: CALLBACK_TYPE | None = None - self._renew_lock = asyncio.Lock() - self._renew_or_restart_job = HassJob( - self._async_renew_or_restart_webhook, - f"{self._name}: renew or restart webhook", - ) - async def async_start(self) -> bool: """Start polling events.""" LOGGER.debug("%s: Starting webhook manager", self._name) @@ -649,20 +516,9 @@ class WebHookManager: async def async_stop(self) -> None: """Unsubscribe from events.""" self.state = WebHookManagerState.STOPPED - self._async_cancel_webhook_renew() await self._async_unsubscribe_webhook() self._async_unregister_webhook() - @callback - def _async_schedule_webhook_renew(self, delay: float) -> None: - """Schedule webhook subscription renewal.""" - self._async_cancel_webhook_renew() - self._cancel_webhook_renew = async_call_later( - self._hass, - delay, - self._renew_or_restart_job, - ) - @retry_connection_error(SUBSCRIPTION_ATTEMPTS) async def _async_create_webhook_subscription(self) -> None: """Create webhook subscription.""" @@ -671,14 +527,12 @@ class WebHookManager: self._name, self._webhook_url, ) - self._notification_manager = self._device.create_notification_manager( - { - "InitialTerminationTime": SUBSCRIPTION_RELATIVE_TIME, - "ConsumerReference": {"Address": self._webhook_url}, - } - ) try: - self._webhook_subscription = await self._notification_manager.setup() + self._notification_manager = await self._device.create_notification_manager( + address=self._webhook_url, + interval=SUBSCRIPTION_TIME, + subscription_lost_callback=self._event_manager.async_mark_events_stale, + ) except ValidationError as err: # This should only happen if there is a problem with the webhook URL # that is causing it to not be well formed. @@ -688,7 +542,7 @@ class WebHookManager: err, ) raise - await self._notification_manager.start() + await self._notification_manager.set_synchronization_point() LOGGER.debug( "%s: Webhook subscription created with URL: %s", self._name, @@ -707,62 +561,8 @@ class WebHookManager: stringify_onvif_error(err), ) return False - - self._async_schedule_webhook_renew(SUBSCRIPTION_RENEW_INTERVAL) return True - async def _async_restart_webhook(self) -> bool: - """Restart the webhook subscription assuming the camera rebooted.""" - await self._async_unsubscribe_webhook() - return await self._async_start_webhook() - - @retry_connection_error(SUBSCRIPTION_ATTEMPTS) - async def _async_call_webhook_subscription_renew(self) -> None: - """Call PullPoint subscription Renew.""" - assert self._webhook_subscription is not None - await self._webhook_subscription.Renew(SUBSCRIPTION_RELATIVE_TIME) - - async def _async_renew_webhook(self) -> bool: - """Renew webhook subscription.""" - if ( - not self._webhook_subscription - or self._webhook_subscription.transport.client.is_closed - ): - return False - try: - await self._async_call_webhook_subscription_renew() - LOGGER.debug("%s: Renewed Webhook subscription", self._name) - return True - except RENEW_ERRORS as err: - self._event_manager.async_mark_events_stale() - LOGGER.debug( - "%s: Failed to renew webhook subscription %s", - self._name, - stringify_onvif_error(err), - ) - return False - - async def _async_renew_or_restart_webhook( - self, now: dt.datetime | None = None - ) -> None: - """Renew or start webhook subscription.""" - if self._hass.is_stopping or self.state != WebHookManagerState.STARTED: - return - if self._renew_lock.locked(): - LOGGER.debug("%s: Webhook renew already in progress", self._name) - # Renew is already running, another one will be - # scheduled when the current one is done if needed. - return - async with self._renew_lock: - next_attempt = SUBSCRIPTION_RESTART_INTERVAL_ON_ERROR - try: - if await self._async_renew_webhook(): - next_attempt = SUBSCRIPTION_RENEW_INTERVAL - else: - await self._async_restart_webhook() - finally: - self._async_schedule_webhook_renew(next_attempt) - @callback def _async_register_webhook(self) -> None: """Register the webhook for motion events.""" @@ -791,6 +591,7 @@ class WebHookManager: LOGGER.debug( "%s: Unregistering webhook %s", self._name, self._webhook_unique_id ) + webhook.async_unregister(self._hass, self._old_webhook_unique_id) webhook.async_unregister(self._hass, self._webhook_unique_id) self._webhook_url = None @@ -842,23 +643,13 @@ class WebHookManager: await event_manager.async_parse_messages(result.NotificationMessage) event_manager.async_callback_listeners() - @callback - def _async_cancel_webhook_renew(self) -> None: - """Cancel the webhook renew task.""" - if self._cancel_webhook_renew: - self._cancel_webhook_renew() - self._cancel_webhook_renew = None - async def _async_unsubscribe_webhook(self) -> None: """Unsubscribe from the webhook.""" - if ( - not self._webhook_subscription - or self._webhook_subscription.transport.client.is_closed - ): + if not self._notification_manager or self._notification_manager.closed: return LOGGER.debug("%s: Unsubscribing from webhook", self._name) try: - await self._webhook_subscription.Unsubscribe() + await self._notification_manager.shutdown() except UNSUBSCRIBE_ERRORS as err: LOGGER.debug( ( @@ -868,4 +659,4 @@ class WebHookManager: self._name, stringify_onvif_error(err), ) - self._webhook_subscription = None + self._notification_manager = None diff --git a/homeassistant/components/onvif/manifest.json b/homeassistant/components/onvif/manifest.json index f29fd5621049..a749e59be48c 100644 --- a/homeassistant/components/onvif/manifest.json +++ b/homeassistant/components/onvif/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/onvif", "iot_class": "local_push", "loggers": ["onvif", "wsdiscovery", "zeep"], - "requirements": ["onvif-zeep-async==2.1.1", "WSDiscovery==2.0.0"] + "requirements": ["onvif-zeep-async==3.1.7", "WSDiscovery==2.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2648aec0b9a6..4edc2275fc55 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1264,7 +1264,7 @@ ondilo==0.2.0 onkyo-eiscp==1.2.7 # homeassistant.components.onvif -onvif-zeep-async==2.1.1 +onvif-zeep-async==3.1.7 # homeassistant.components.opengarage open-garage==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5c30dc4da572..f7bfe75aaafe 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -954,7 +954,7 @@ omnilogic==0.4.5 ondilo==0.2.0 # homeassistant.components.onvif -onvif-zeep-async==2.1.1 +onvif-zeep-async==3.1.7 # homeassistant.components.opengarage open-garage==0.2.0 From 77f99e416251015d6194f6924a1ba40e63a06a53 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 15 May 2023 00:11:12 -0700 Subject: [PATCH 351/822] Add MEASUREMENT state class to Sense Voltage sensors (#93044) Add MEASUREMENT to Voltage sensors --- homeassistant/components/sense/sensor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/sense/sensor.py b/homeassistant/components/sense/sensor.py index 9a3bb8bc3f0e..d6679d80f69b 100644 --- a/homeassistant/components/sense/sensor.py +++ b/homeassistant/components/sense/sensor.py @@ -210,6 +210,7 @@ class SenseVoltageSensor(SensorEntity): """Implementation of a Sense energy voltage sensor.""" _attr_device_class = SensorDeviceClass.VOLTAGE + _attr_state_class = SensorStateClass.MEASUREMENT _attr_native_unit_of_measurement = UnitOfElectricPotential.VOLT _attr_attribution = ATTRIBUTION _attr_should_poll = False From 55c675d0fd40cf1e857d98c854937e62f4f7a2f0 Mon Sep 17 00:00:00 2001 From: Stephan Uhle Date: Mon, 15 May 2023 09:15:22 +0200 Subject: [PATCH 352/822] Bump pysml to 0.0.11 (#93035) Updated pysml to 0.0.11 --- homeassistant/components/edl21/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/edl21/manifest.json b/homeassistant/components/edl21/manifest.json index 034d3fcae2e1..5fdda4634591 100644 --- a/homeassistant/components/edl21/manifest.json +++ b/homeassistant/components/edl21/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["sml"], - "requirements": ["pysml==0.0.10"] + "requirements": ["pysml==0.0.11"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4edc2275fc55..dd8b4a7983c9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1981,7 +1981,7 @@ pysmartapp==0.3.3 pysmartthings==0.7.6 # homeassistant.components.edl21 -pysml==0.0.10 +pysml==0.0.11 # homeassistant.components.snmp pysnmplib==5.0.21 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f7bfe75aaafe..5851835eefe4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1458,7 +1458,7 @@ pysmartapp==0.3.3 pysmartthings==0.7.6 # homeassistant.components.edl21 -pysml==0.0.10 +pysml==0.0.11 # homeassistant.components.snmp pysnmplib==5.0.21 From 01dc27e2a28253a3fa58fadd56edf220b6a9485b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jens=20=C3=98stergaard=20Nielsen?= Date: Mon, 15 May 2023 09:50:39 +0200 Subject: [PATCH 353/822] Remove default unit of measurement in IHC sensors (#93054) Remove sensor default unit of measurement --- homeassistant/components/ihc/manual_setup.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/homeassistant/components/ihc/manual_setup.py b/homeassistant/components/ihc/manual_setup.py index c14d387ba618..b4775f9193b9 100644 --- a/homeassistant/components/ihc/manual_setup.py +++ b/homeassistant/components/ihc/manual_setup.py @@ -12,7 +12,6 @@ from homeassistant.const import ( CONF_UNIT_OF_MEASUREMENT, CONF_URL, CONF_USERNAME, - UnitOfTemperature, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import discovery @@ -81,11 +80,7 @@ LIGHT_SCHEMA = DEVICE_SCHEMA.extend( ) SENSOR_SCHEMA = DEVICE_SCHEMA.extend( - { - vol.Optional( - CONF_UNIT_OF_MEASUREMENT, default=UnitOfTemperature.CELSIUS - ): cv.string - } + {vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string} ) IHC_SCHEMA = vol.Schema( From 48ab74eedfb9e28a62ef2cf86b0c4520d56e7581 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 15 May 2023 11:15:19 +0200 Subject: [PATCH 354/822] Add missing mock in wiffi tests (#93083) --- tests/components/wiffi/conftest.py | 14 ++++++++++++++ tests/components/wiffi/test_config_flow.py | 2 ++ 2 files changed, 16 insertions(+) create mode 100644 tests/components/wiffi/conftest.py diff --git a/tests/components/wiffi/conftest.py b/tests/components/wiffi/conftest.py new file mode 100644 index 000000000000..bceded737c6d --- /dev/null +++ b/tests/components/wiffi/conftest.py @@ -0,0 +1,14 @@ +"""Configuration for Wiffi 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.wiffi.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/wiffi/test_config_flow.py b/tests/components/wiffi/test_config_flow.py index 0c069180e899..bf0b072df8c4 100644 --- a/tests/components/wiffi/test_config_flow.py +++ b/tests/components/wiffi/test_config_flow.py @@ -14,6 +14,8 @@ from tests.common import MockConfigEntry MOCK_CONFIG = {CONF_PORT: 8765} +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + @pytest.fixture(name="dummy_tcp_server") def mock_dummy_tcp_server(): From d12ea0040f4a21c00ac964ab75837a9ebb9cbe8a Mon Sep 17 00:00:00 2001 From: Daniel Gangl <31815106+killer0071234@users.noreply.github.com> Date: Mon, 15 May 2023 19:26:02 +0200 Subject: [PATCH 355/822] Fix weather handling in zamg (#85635) * TypeError handling in weather * Check for None * Use walrus operator as proposed --- homeassistant/components/zamg/weather.py | 42 +++++++++++++++++++----- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/zamg/weather.py b/homeassistant/components/zamg/weather.py index 46913e90516a..f94f9ca8a3a8 100644 --- a/homeassistant/components/zamg/weather.py +++ b/homeassistant/components/zamg/weather.py @@ -64,8 +64,16 @@ class ZamgWeather(CoordinatorEntity, WeatherEntity): def native_temperature(self) -> float | None: """Return the platform temperature.""" try: - return float(self.coordinator.data[self.station_id]["TL"]["data"]) - except (KeyError, ValueError): + if ( + value := self.coordinator.data[self.station_id]["TLAM"]["data"] + ) is not None: + return float(value) + if ( + value := self.coordinator.data[self.station_id]["TL"]["data"] + ) is not None: + return float(value) + return None + except (KeyError, ValueError, TypeError): return None @property @@ -73,7 +81,7 @@ class ZamgWeather(CoordinatorEntity, WeatherEntity): """Return the pressure.""" try: return float(self.coordinator.data[self.station_id]["P"]["data"]) - except (KeyError, ValueError): + except (KeyError, ValueError, TypeError): return None @property @@ -81,21 +89,37 @@ class ZamgWeather(CoordinatorEntity, WeatherEntity): """Return the humidity.""" try: return float(self.coordinator.data[self.station_id]["RFAM"]["data"]) - except (KeyError, ValueError): + except (KeyError, ValueError, TypeError): return None @property def native_wind_speed(self) -> float | None: """Return the wind speed.""" try: - return float(self.coordinator.data[self.station_id]["FFAM"]["data"]) - except (KeyError, ValueError): + if ( + value := self.coordinator.data[self.station_id]["FFAM"]["data"] + ) is not None: + return float(value) + if ( + value := self.coordinator.data[self.station_id]["FFX"]["data"] + ) is not None: + return float(value) + return None + except (KeyError, ValueError, TypeError): return None @property - def wind_bearing(self) -> float | str | None: + def wind_bearing(self) -> float | None: """Return the wind bearing.""" try: - return self.coordinator.data[self.station_id]["DD"]["data"] - except (KeyError, ValueError): + if ( + value := self.coordinator.data[self.station_id]["DD"]["data"] + ) is not None: + return float(value) + if ( + value := self.coordinator.data[self.station_id]["DDX"]["data"] + ) is not None: + return float(value) + return None + except (KeyError, ValueError, TypeError): return None From c97e0f11a51258b3e1e406a4c738f1ef00500a72 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 15 May 2023 21:15:10 +0200 Subject: [PATCH 356/822] Fix last imap message is not reset on empty search (#93119) --- homeassistant/components/imap/coordinator.py | 4 +- tests/components/imap/test_init.py | 99 ++++++++++++++++++++ 2 files changed, 102 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index bdf1390ae208..0f53c5928c95 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -201,7 +201,9 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): raise UpdateFailed( f"Invalid response for search '{self.config_entry.data[CONF_SEARCH]}': {result} / {lines[0]}" ) - count: int = len(message_ids := lines[0].split()) + if not (count := len(message_ids := lines[0].split())): + self._last_message_id = None + return 0 last_message_id = ( str(message_ids[-1:][0], encoding=self.config_entry.data[CONF_CHARSET]) if count diff --git a/tests/components/imap/test_init.py b/tests/components/imap/test_init.py index 58bb084296ab..8f00cf395d2b 100644 --- a/tests/components/imap/test_init.py +++ b/tests/components/imap/test_init.py @@ -15,6 +15,7 @@ from homeassistant.util.dt import utcnow from .const import ( BAD_RESPONSE, + EMPTY_SEARCH_RESPONSE, TEST_FETCH_RESPONSE_BINARY, TEST_FETCH_RESPONSE_HTML, TEST_FETCH_RESPONSE_INVALID_DATE, @@ -347,3 +348,101 @@ async def test_fetch_number_of_messages( # we should have an entity with an unavailable state assert state is not None assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize("imap_search", [TEST_SEARCH_RESPONSE]) +@pytest.mark.parametrize( + ("imap_fetch", "valid_date"), + [(TEST_FETCH_RESPONSE_TEXT_PLAIN, True)], + ids=["plain"], +) +@pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) +async def test_reset_last_message( + hass: HomeAssistant, mock_imap_protocol: MagicMock, valid_date: bool +) -> None: + """Test receiving a message successfully.""" + event = asyncio.Event() # needed for pushed coordinator to make a new loop + + async def _sleep_till_event() -> None: + """Simulate imap server waiting for pushes message and keep the push loop going. + + Needed for pushed coordinator only. + """ + nonlocal event + await event.wait() + event.clear() + mock_imap_protocol.idle_start.return_value = AsyncMock()() + + # Make sure we make another cycle (needed for pushed coordinator) + mock_imap_protocol.idle_start.return_value = AsyncMock()() + # Mock we wait till we push an update (needed for pushed coordinator) + mock_imap_protocol.wait_server_push.side_effect = _sleep_till_event + + event_called = async_capture_events(hass, "imap_content") + + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + # Make sure we have had one update (when polling) + async_fire_time_changed(hass, utcnow() + timedelta(seconds=5)) + await hass.async_block_till_done() + state = hass.states.get("sensor.imap_email_email_com") + # We should have received one message + assert state is not None + assert state.state == "1" + + # We should have received one event + assert len(event_called) == 1 + data: dict[str, Any] = event_called[0].data + assert data["server"] == "imap.server.com" + assert data["username"] == "email@email.com" + assert data["search"] == "UnSeen UnDeleted" + assert data["folder"] == "INBOX" + assert data["sender"] == "john.doe@example.com" + assert data["subject"] == "Test subject" + assert data["text"] + assert ( + valid_date + and isinstance(data["date"], datetime) + or not valid_date + and data["date"] is None + ) + + # Simulate an update where no messages are found (needed for pushed coordinator) + mock_imap_protocol.search.return_value = Response(*EMPTY_SEARCH_RESPONSE) + + # Make sure we have an update + async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) + + # Awake loop (needed for pushed coordinator) + event.set() + + await hass.async_block_till_done() + + state = hass.states.get("sensor.imap_email_email_com") + # We should have message + assert state is not None + assert state.state == "0" + # No new events should be called + assert len(event_called) == 1 + + # Simulate an update where with the original message + mock_imap_protocol.search.return_value = Response(*TEST_SEARCH_RESPONSE) + # Make sure we have an update again with the same UID + async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) + + # Awake loop (needed for pushed coordinator) + event.set() + + await hass.async_block_till_done() + + state = hass.states.get("sensor.imap_email_email_com") + # We should have received one message + assert state is not None + assert state.state == "1" + await hass.async_block_till_done() + await hass.async_block_till_done() + + # One new event + assert len(event_called) == 2 From cf957f1880327072a8e22744ac7d2d8bf1f99995 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Mon, 15 May 2023 21:19:27 +0200 Subject: [PATCH 357/822] Bump aioecowitt 2023.5.0 (#93094) --- homeassistant/components/ecowitt/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ecowitt/manifest.json b/homeassistant/components/ecowitt/manifest.json index 546202fa9c53..9f0f668ee81f 100644 --- a/homeassistant/components/ecowitt/manifest.json +++ b/homeassistant/components/ecowitt/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["webhook"], "documentation": "https://www.home-assistant.io/integrations/ecowitt", "iot_class": "local_push", - "requirements": ["aioecowitt==2023.01.0"] + "requirements": ["aioecowitt==2023.5.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index dd8b4a7983c9..380b00963fb3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -150,7 +150,7 @@ aioeafm==0.1.2 aioeagle==1.1.0 # homeassistant.components.ecowitt -aioecowitt==2023.01.0 +aioecowitt==2023.5.0 # homeassistant.components.emonitor aioemonitor==1.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5851835eefe4..6da78a2f37e2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -140,7 +140,7 @@ aioeafm==0.1.2 aioeagle==1.1.0 # homeassistant.components.ecowitt -aioecowitt==2023.01.0 +aioecowitt==2023.5.0 # homeassistant.components.emonitor aioemonitor==1.0.5 From b38a7aabe9bcab2cf59420d28bc5819ed9220155 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 15 May 2023 21:20:20 +0200 Subject: [PATCH 358/822] Fix lingering timer in withings (#93085) --- homeassistant/components/withings/__init__.py | 2 +- tests/components/withings/test_init.py | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index 94aaa9aa5a6a..682efde88816 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -151,7 +151,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) # Start subscription check in the background, outside this component's setup. - async_call_later(hass, 1, async_call_later_callback) + entry.async_on_unload(async_call_later(hass, 1, async_call_later_callback)) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/tests/components/withings/test_init.py b/tests/components/withings/test_init.py index 66d2f0eb29dc..9ccc53d0b883 100644 --- a/tests/components/withings/test_init.py +++ b/tests/components/withings/test_init.py @@ -165,7 +165,11 @@ async def test_set_config_unique_id( config_entry = MockConfigEntry( domain=DOMAIN, - data={"token": {"userid": "my_user_id"}, "profile": person0.profile}, + data={ + "token": {"userid": "my_user_id"}, + "auth_implementation": "withings", + "profile": person0.profile, + }, ) with patch("homeassistant.components.withings.async_get_data_manager") as mock: From 209650dca199d1ebc48aea825003b91cfa4cbf5d Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Mon, 15 May 2023 21:23:24 +0200 Subject: [PATCH 359/822] Remove snapcast friendly_name attribute (#93116) Remove friendly_name attribute and fix names --- homeassistant/components/snapcast/media_player.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/snapcast/media_player.py b/homeassistant/components/snapcast/media_player.py index 4fd7c587d40c..bb54bfabf9f0 100644 --- a/homeassistant/components/snapcast/media_player.py +++ b/homeassistant/components/snapcast/media_player.py @@ -175,7 +175,7 @@ class SnapcastGroupDevice(MediaPlayerEntity): @property def name(self): """Return the name of the device.""" - return f"{GROUP_PREFIX}{self._group.identifier}" + return f"{self._group.friendly_name} {GROUP_SUFFIX}" @property def source(self): @@ -197,12 +197,6 @@ class SnapcastGroupDevice(MediaPlayerEntity): """List of available input sources.""" return list(self._group.streams_by_name().keys()) - @property - def extra_state_attributes(self): - """Return the state attributes.""" - name = f"{self._group.friendly_name} {GROUP_SUFFIX}" - return {"friendly_name": name} - async def async_select_source(self, source: str) -> None: """Set input source.""" streams = self._group.streams_by_name() @@ -270,7 +264,7 @@ class SnapcastClientDevice(MediaPlayerEntity): @property def name(self): """Return the name of the device.""" - return f"{CLIENT_PREFIX}{self._client.identifier}" + return f"{self._client.friendly_name} {CLIENT_SUFFIX}" @property def source(self): @@ -307,8 +301,6 @@ class SnapcastClientDevice(MediaPlayerEntity): state_attrs = {} if self.latency is not None: state_attrs["latency"] = self.latency - name = f"{self._client.friendly_name} {CLIENT_SUFFIX}" - state_attrs["friendly_name"] = name return state_attrs @property From 64623283b297326a583d7beb714d444084b3cec9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 15 May 2023 14:24:17 -0500 Subject: [PATCH 360/822] Bump aioesphomeapi to 13.7.5 (#93121) performance improvements changelog: https://github.com/esphome/aioesphomeapi/compare/v13.7.4...v13.7.5 --- 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 0e9715038f06..49057080469c 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -15,7 +15,7 @@ "iot_class": "local_push", "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ - "aioesphomeapi==13.7.4", + "aioesphomeapi==13.7.5", "bluetooth-data-tools==0.4.0", "esphome-dashboard-api==1.2.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index 380b00963fb3..fa21240f38b3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -156,7 +156,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==13.7.4 +aioesphomeapi==13.7.5 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6da78a2f37e2..d5ce4b41f645 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -146,7 +146,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==13.7.4 +aioesphomeapi==13.7.5 # homeassistant.components.flo aioflo==2021.11.0 From b400c53159f46b7e9de7b4a45bee1eb78f3982dc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 15 May 2023 14:26:29 -0500 Subject: [PATCH 361/822] Bump pyunifiprotect to 4.9.0 (#93106) changelog: https://github.com/AngellusMortis/pyunifiprotect/compare/v4.8.3...v4.9.0 --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index afa7f2b5d4b8..fcb30cdba5fb 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.8.3", "unifi-discovery==1.1.7"], + "requirements": ["pyunifiprotect==4.9.0", "unifi-discovery==1.1.7"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index fa21240f38b3..5e379c7878aa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2158,7 +2158,7 @@ pytrafikverket==0.3.3 pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.8.3 +pyunifiprotect==4.9.0 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d5ce4b41f645..3450179f3c95 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1566,7 +1566,7 @@ pytrafikverket==0.3.3 pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.8.3 +pyunifiprotect==4.9.0 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 From d8d44465630268033ef3127487b876ee2e135b75 Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Mon, 15 May 2023 21:29:28 +0200 Subject: [PATCH 362/822] Bump snapcast to 2.3.3 (#93112) bump snapcast to 2.3.3 --- homeassistant/components/snapcast/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/snapcast/manifest.json b/homeassistant/components/snapcast/manifest.json index 8701fca0ad46..13091689fed8 100644 --- a/homeassistant/components/snapcast/manifest.json +++ b/homeassistant/components/snapcast/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/snapcast", "iot_class": "local_polling", "loggers": ["construct", "snapcast"], - "requirements": ["snapcast==2.3.2"] + "requirements": ["snapcast==2.3.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5e379c7878aa..c8a34277bd1b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2372,7 +2372,7 @@ smart-meter-texas==0.4.7 smhi-pkg==1.0.16 # homeassistant.components.snapcast -snapcast==2.3.2 +snapcast==2.3.3 # homeassistant.components.sonos soco==0.29.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3450179f3c95..3907fbe9ee3a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1711,7 +1711,7 @@ smart-meter-texas==0.4.7 smhi-pkg==1.0.16 # homeassistant.components.snapcast -snapcast==2.3.2 +snapcast==2.3.3 # homeassistant.components.sonos soco==0.29.1 From 72a6d3a74896b6af96976d16cfc74d5ced83bde3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 15 May 2023 14:29:41 -0500 Subject: [PATCH 363/822] Re-enable cleanup_closed on cpython >= 3.11.4 (#93120) --- homeassistant/helpers/aiohttp_client.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index 78806cb5ae17..8208c7748870 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -37,10 +37,11 @@ SERVER_SOFTWARE = "{0}/{1} aiohttp/{2} Python/{3[0]}.{3[1]}".format( APPLICATION_NAME, __version__, aiohttp.__version__, sys.version_info ) -ENABLE_CLEANUP_CLOSED = sys.version_info < (3, 11, 1) +ENABLE_CLEANUP_CLOSED = not (3, 11, 1) <= sys.version_info < (3, 11, 4) # Enabling cleanup closed on python 3.11.1+ leaks memory relatively quickly # see https://github.com/aio-libs/aiohttp/issues/7252 # aiohttp interacts poorly with https://github.com/python/cpython/pull/98540 +# The issue was fixed in 3.11.4 via https://github.com/python/cpython/pull/104485 WARN_CLOSE_MSG = "closes the Home Assistant aiohttp session" From 2f8e8901fc8dd9d7c0cfccea50d9896dda990ebd Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Mon, 15 May 2023 22:59:29 +0200 Subject: [PATCH 364/822] Add KNX interface device trigger for telegrams (#93102) * telegram device trigger initial * add Telegrams helper class to parse and convert Telegram only once instead of once per device trigger * translation * label for extra_field * test device trigger * test trigger callback removal * rename extra_field key to same name as used in trigger * typo --- homeassistant/components/knx/__init__.py | 2 + .../components/knx/device_trigger.py | 103 +++++++++ homeassistant/components/knx/strings.json | 5 + homeassistant/components/knx/telegrams.py | 105 ++++++++++ tests/components/knx/conftest.py | 1 + tests/components/knx/test_device_trigger.py | 197 ++++++++++++++++++ 6 files changed, 413 insertions(+) create mode 100644 homeassistant/components/knx/device_trigger.py create mode 100644 homeassistant/components/knx/telegrams.py create mode 100644 tests/components/knx/test_device_trigger.py diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index 8fab84c0aea6..e177a861af34 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -92,6 +92,7 @@ from .schema import ( ga_validator, sensor_type_validator, ) +from .telegrams import Telegrams from .websocket import register_panel _LOGGER = logging.getLogger(__name__) @@ -383,6 +384,7 @@ class KNXModule: self.xknx.connection_manager.register_connection_state_changed_cb( self.connection_state_changed_cb ) + self.telegrams = Telegrams(hass, self.xknx, self.project) self.interface_device = KNXInterfaceDevice( hass=hass, entry=entry, xknx=self.xknx ) diff --git a/homeassistant/components/knx/device_trigger.py b/homeassistant/components/knx/device_trigger.py new file mode 100644 index 000000000000..8a074b43b7dd --- /dev/null +++ b/homeassistant/components/knx/device_trigger.py @@ -0,0 +1,103 @@ +"""Provides device triggers for KNX.""" +from __future__ import annotations + +from typing import Any, Final + +import voluptuous as vol + +from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA +from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE +from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback +from homeassistant.helpers import selector +from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo +from homeassistant.helpers.typing import ConfigType + +from . import KNXModule +from .const import DOMAIN +from .project import KNXProject +from .schema import ga_list_validator +from .telegrams import TelegramDict + +TRIGGER_TELEGRAM: Final = "telegram" +EXTRA_FIELD_DESTINATION: Final = "destination" # no translation support + +TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( + { + vol.Optional(EXTRA_FIELD_DESTINATION): ga_list_validator, + vol.Required(CONF_TYPE): TRIGGER_TELEGRAM, + } +) + + +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: + """List device triggers for KNX devices.""" + triggers = [] + + knx: KNXModule = hass.data[DOMAIN] + if knx.interface_device.device.id == device_id: + # Add trigger for KNX telegrams to interface device + triggers.append( + { + # Required fields of TRIGGER_BASE_SCHEMA + CONF_PLATFORM: "device", + CONF_DOMAIN: DOMAIN, + CONF_DEVICE_ID: device_id, + # Required fields of TRIGGER_SCHEMA + CONF_TYPE: TRIGGER_TELEGRAM, + } + ) + + return triggers + + +async def async_get_trigger_capabilities( + hass: HomeAssistant, config: ConfigType +) -> dict[str, vol.Schema]: + """List trigger capabilities.""" + project: KNXProject = hass.data[DOMAIN].project + options = [ + selector.SelectOptionDict(value=ga.address, label=f"{ga.address} - {ga.name}") + for ga in project.group_addresses.values() + ] + return { + "extra_fields": vol.Schema( + { + vol.Optional(EXTRA_FIELD_DESTINATION): selector.SelectSelector( + selector.SelectSelectorConfig( + mode=selector.SelectSelectorMode.DROPDOWN, + multiple=True, + custom_value=True, + options=options, + ), + ), + } + ) + } + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: TriggerActionType, + trigger_info: TriggerInfo, +) -> CALLBACK_TYPE: + """Attach a trigger.""" + dst_addresses: list[str] = config.get(EXTRA_FIELD_DESTINATION, []) + job = HassJob(action, f"KNX device trigger {trigger_info}") + knx: KNXModule = hass.data[DOMAIN] + + @callback + def async_call_trigger_action(telegram: TelegramDict) -> None: + """Filter Telegram and call trigger action.""" + if dst_addresses and telegram["destination"] not in dst_addresses: + return + hass.async_run_hass_job( + job, + {"trigger": telegram}, + ) + + return knx.telegrams.async_listen_telegram( + async_call_trigger_action, name="KNX device trigger call" + ) diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json index 0fce778c5218..d4a1eae11c2f 100644 --- a/homeassistant/components/knx/strings.json +++ b/homeassistant/components/knx/strings.json @@ -281,5 +281,10 @@ "name": "Telegrams" } } + }, + "device_automation": { + "trigger_type": { + "telegram": "Telegram sent or received" + } } } diff --git a/homeassistant/components/knx/telegrams.py b/homeassistant/components/knx/telegrams.py new file mode 100644 index 000000000000..815a0c00a931 --- /dev/null +++ b/homeassistant/components/knx/telegrams.py @@ -0,0 +1,105 @@ +"""KNX Telegram handler.""" +from __future__ import annotations + +from collections.abc import Callable +from typing import TypedDict + +from xknx import XKNX +from xknx.exceptions import XKNXException +from xknx.telegram import Telegram +from xknx.telegram.apci import GroupValueResponse, GroupValueWrite + +from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback + +from .project import KNXProject + + +class TelegramDict(TypedDict): + """Represent a Telegram as a dict.""" + + destination: str + destination_name: str + direction: str + payload: int | tuple[int, ...] | None + source: str + source_name: str + telegramtype: str + value: str | int | float | bool | None + + +class Telegrams: + """Class to handle KNX telegrams.""" + + def __init__(self, hass: HomeAssistant, xknx: XKNX, project: KNXProject) -> None: + """Initialize Telegrams class.""" + self.hass = hass + self.project = project + self._jobs: list[HassJob[[TelegramDict], None]] = [] + self._xknx_telegram_cb_handle = ( + xknx.telegram_queue.register_telegram_received_cb( + telegram_received_cb=self._xknx_telegram_cb, + match_for_outgoing=True, + ) + ) + + async def _xknx_telegram_cb(self, telegram: Telegram) -> None: + """Handle incoming and outgoing telegrams from xknx.""" + telegram_dict = self.telegram_to_dict(telegram) + for job in self._jobs: + self.hass.async_run_hass_job(job, telegram_dict) + + @callback + def async_listen_telegram( + self, + action: Callable[[TelegramDict], None], + name: str = "KNX telegram listener", + ) -> CALLBACK_TYPE: + """Register callback to listen for telegrams.""" + job = HassJob(action, name=name) + self._jobs.append(job) + + def remove_listener() -> None: + """Remove the listener.""" + self._jobs.remove(job) + + return remove_listener + + def telegram_to_dict(self, telegram: Telegram) -> TelegramDict: + """Convert a Telegram to a dict.""" + dst_name = "" + payload_data: int | tuple[int, ...] | None = None + src_name = "" + transcoder = None + value: str | int | float | bool | None = None + + if ( + ga_info := self.project.group_addresses.get( + f"{telegram.destination_address}" + ) + ) is not None: + dst_name = ga_info.name + transcoder = ga_info.transcoder + + if ( + device := self.project.devices.get(f"{telegram.source_address}") + ) is not None: + src_name = device["name"] + + if isinstance(telegram.payload, (GroupValueWrite, GroupValueResponse)): + payload_data = telegram.payload.value.value + if transcoder is not None: + try: + value = transcoder.from_knx(telegram.payload.value) + except XKNXException: + value = None + + return TelegramDict( + destination=f"{telegram.destination_address}", + destination_name=dst_name, + direction=telegram.direction.value, + payload=payload_data, + source=f"{telegram.source_address}", + source_name=src_name, + telegramtype=telegram.payload.__class__.__name__, + value=value, + ) diff --git a/tests/components/knx/conftest.py b/tests/components/knx/conftest.py index fcad9a7be89b..79a3de917154 100644 --- a/tests/components/knx/conftest.py +++ b/tests/components/knx/conftest.py @@ -143,6 +143,7 @@ class KNXTestKit: """Assert outgoing telegram. One by one in timely order.""" await self.xknx.telegrams.join() await self.hass.async_block_till_done() + await self.hass.async_block_till_done() try: telegram = self._outgoing_telegrams.get_nowait() except asyncio.QueueEmpty: diff --git a/tests/components/knx/test_device_trigger.py b/tests/components/knx/test_device_trigger.py new file mode 100644 index 000000000000..c70639975858 --- /dev/null +++ b/tests/components/knx/test_device_trigger.py @@ -0,0 +1,197 @@ +"""Tests for KNX device triggers.""" +import pytest +import voluptuous_serialize + +from homeassistant.components import automation +from homeassistant.components.device_automation import DeviceAutomationType +from homeassistant.components.knx import DOMAIN, device_trigger +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.setup import async_setup_component + +from .conftest import KNXTestKit + +from tests.common import async_get_device_automations, async_mock_service + + +@pytest.fixture +def calls(hass: HomeAssistant) -> list[ServiceCall]: + """Track calls to a mock service.""" + return async_mock_service(hass, "test", "automation") + + +async def test_get_triggers( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + knx: KNXTestKit, +) -> None: + """Test we get the expected triggers from knx.""" + await knx.setup_integration({}) + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, f"_{knx.mock_config_entry.entry_id}_interface")} + ) + expected_trigger = { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "type": "telegram", + "metadata": {}, + } + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) + assert expected_trigger in triggers + + +async def test_if_fires_on_telegram( + hass: HomeAssistant, + calls: list[ServiceCall], + device_registry: dr.DeviceRegistry, + knx: KNXTestKit, +) -> None: + """Test for telegram triggers firing.""" + await knx.setup_integration({}) + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, f"_{knx.mock_config_entry.entry_id}_interface")} + ) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "type": "telegram", + }, + "action": { + "service": "test.automation", + "data_template": { + "catch_all": ("telegram - {{ trigger.destination }}") + }, + }, + }, + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "type": "telegram", + "destination": ["1/2/3", "1/2/4"], + }, + "action": { + "service": "test.automation", + "data_template": { + "specific": ("telegram - {{ trigger.destination }}") + }, + }, + }, + ] + }, + ) + + await knx.receive_write("0/0/1", (0x03, 0x2F)) + assert len(calls) == 1 + assert calls.pop().data["catch_all"] == "telegram - 0/0/1" + + await knx.receive_write("1/2/4", (0x03, 0x2F)) + assert len(calls) == 2 + assert calls.pop().data["specific"] == "telegram - 1/2/4" + assert calls.pop().data["catch_all"] == "telegram - 1/2/4" + + +async def test_remove_device_trigger( + hass: HomeAssistant, + calls: list[ServiceCall], + device_registry: dr.DeviceRegistry, + knx: KNXTestKit, +) -> None: + """Test for removed callback when device trigger not used.""" + automation_name = "telegram_trigger_automation" + await knx.setup_integration({}) + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, f"_{knx.mock_config_entry.entry_id}_interface")} + ) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "alias": automation_name, + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "type": "telegram", + }, + "action": { + "service": "test.automation", + "data_template": { + "catch_all": ("telegram - {{ trigger.destination }}") + }, + }, + } + ] + }, + ) + + assert len(hass.data[DOMAIN].telegrams._jobs) == 1 + await knx.receive_write("0/0/1", (0x03, 0x2F)) + assert len(calls) == 1 + assert calls.pop().data["catch_all"] == "telegram - 0/0/1" + + await hass.services.async_call( + automation.DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: f"automation.{automation_name}"}, + blocking=True, + ) + + assert len(hass.data[DOMAIN].telegrams._jobs) == 0 + await knx.receive_write("0/0/1", (0x03, 0x2F)) + assert len(calls) == 0 + + +async def test_get_trigger_capabilities_node_status( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + knx: KNXTestKit, +) -> None: + """Test we get the expected capabilities from a node_status trigger.""" + await knx.setup_integration({}) + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, f"_{knx.mock_config_entry.entry_id}_interface")} + ) + + capabilities = await device_trigger.async_get_trigger_capabilities( + hass, + { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "type": "telegram", + }, + ) + assert capabilities and "extra_fields" in capabilities + + assert voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) == [ + { + "name": "destination", + "optional": True, + "selector": { + "select": { + "custom_value": True, + "mode": "dropdown", + "multiple": True, + "options": [], + }, + }, + } + ] From 36332eb441812fa71a024b78b81116e516e61dd2 Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Tue, 16 May 2023 03:12:44 +0200 Subject: [PATCH 365/822] Bump devolo_plc_api to 1.3.1 (#93099) --- homeassistant/components/devolo_home_network/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/devolo_home_network/manifest.json b/homeassistant/components/devolo_home_network/manifest.json index e3aaf2beaf97..e635b1f7021c 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.2.0"], + "requirements": ["devolo-plc-api==1.3.1"], "zeroconf": [ { "type": "_dvl-deviceapi._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index c8a34277bd1b..219f3c728c8b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -596,7 +596,7 @@ denonavr==0.11.2 devolo-home-control-api==0.18.2 # homeassistant.components.devolo_home_network -devolo-plc-api==1.2.0 +devolo-plc-api==1.3.1 # homeassistant.components.directv directv==0.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3907fbe9ee3a..99b4d4f82faa 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -479,7 +479,7 @@ denonavr==0.11.2 devolo-home-control-api==0.18.2 # homeassistant.components.devolo_home_network -devolo-plc-api==1.2.0 +devolo-plc-api==1.3.1 # homeassistant.components.directv directv==0.4.0 From f6b700372a225f1519a4da03f7bacbadfa89bea8 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 16 May 2023 06:31:31 +0200 Subject: [PATCH 366/822] Allow wemo config entry to be unloaded (#93082) * Fix lingering timer in wemo * Also stop the subscriptions --- homeassistant/components/wemo/__init__.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/wemo/__init__.py b/homeassistant/components/wemo/__init__.py index 11a46293f29a..b208a30f5ec7 100644 --- a/homeassistant/components/wemo/__init__.py +++ b/homeassistant/components/wemo/__init__.py @@ -113,7 +113,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: wemo_dispatcher = WemoDispatcher(entry) wemo_discovery = WemoDiscovery(hass, wemo_dispatcher, static_conf) - async def async_stop_wemo(event: Event) -> None: + async def async_stop_wemo(_: Event | None = None) -> None: """Shutdown Wemo subscriptions and subscription thread on exit.""" _LOGGER.debug("Shutting down WeMo event subscriptions") await hass.async_add_executor_job(registry.stop) @@ -123,6 +123,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop_wemo) ) + entry.async_on_unload(async_stop_wemo) # Need to do this at least once in case statistics are defined and discovery is disabled await wemo_discovery.discover_statics() @@ -133,6 +134,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a wemo config entry.""" + # This makes sure that `entry.async_on_unload` routines run correctly on unload + return True + + class WemoDispatcher: """Dispatch WeMo devices to the correct platform.""" From 9c039a17ea232a23a3a55936d44b66366738020b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 16 May 2023 01:26:17 -0500 Subject: [PATCH 367/822] Bump guppy3 to 3.1.3 (#93124) --- homeassistant/components/profiler/__init__.py | 5 ----- .../components/profiler/manifest.json | 6 +---- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/profiler/test_init.py | 22 ------------------- 5 files changed, 3 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/profiler/__init__.py b/homeassistant/components/profiler/__init__.py index f558b7301c5f..ba5f25a1c024 100644 --- a/homeassistant/components/profiler/__init__.py +++ b/homeassistant/components/profiler/__init__.py @@ -403,11 +403,6 @@ async def _async_generate_memory_profile(hass: HomeAssistant, call: ServiceCall) # Imports deferred to avoid loading modules # in memory since usually only one part of this # integration is used at a time - if sys.version_info >= (3, 11): - raise HomeAssistantError( - "Memory profiling is not supported on Python 3.11. Please use Python 3.10." - ) - from guppy import hpy # pylint: disable=import-outside-toplevel start_time = int(time.time() * 1000000) diff --git a/homeassistant/components/profiler/manifest.json b/homeassistant/components/profiler/manifest.json index 81eb77537fb7..1b33c7788438 100644 --- a/homeassistant/components/profiler/manifest.json +++ b/homeassistant/components/profiler/manifest.json @@ -5,9 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/profiler", "quality_scale": "internal", - "requirements": [ - "pyprof2calltree==1.4.5", - "guppy3==3.1.2;python_version<'3.11'", - "objgraph==3.5.0" - ] + "requirements": ["pyprof2calltree==1.4.5", "guppy3==3.1.3", "objgraph==3.5.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 219f3c728c8b..6357efbf5a08 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -856,7 +856,7 @@ gspread==5.5.0 gstreamer-player==1.1.2 # homeassistant.components.profiler -guppy3==3.1.2;python_version<'3.11' +guppy3==3.1.3 # homeassistant.components.iaqualink h2==4.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 99b4d4f82faa..25f47998dce3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -660,7 +660,7 @@ growattServer==1.3.0 gspread==5.5.0 # homeassistant.components.profiler -guppy3==3.1.2;python_version<'3.11' +guppy3==3.1.3 # homeassistant.components.iaqualink h2==4.1.0 diff --git a/tests/components/profiler/test_init.py b/tests/components/profiler/test_init.py index ca998f25f5f0..6f61b45518be 100644 --- a/tests/components/profiler/test_init.py +++ b/tests/components/profiler/test_init.py @@ -3,7 +3,6 @@ from datetime import timedelta from functools import lru_cache import os from pathlib import Path -import sys from unittest.mock import patch from lru import LRU # pylint: disable=no-name-in-module @@ -64,9 +63,6 @@ async def test_basic_usage(hass: HomeAssistant, tmp_path: Path) -> None: await hass.async_block_till_done() -@pytest.mark.skipif( - sys.version_info >= (3, 11), reason="not yet available on python 3.11" -) async def test_memory_usage(hass: HomeAssistant, tmp_path: Path) -> None: """Test we can setup and the service is registered.""" test_dir = tmp_path / "profiles" @@ -98,24 +94,6 @@ async def test_memory_usage(hass: HomeAssistant, tmp_path: Path) -> None: await hass.async_block_till_done() -@pytest.mark.skipif(sys.version_info < (3, 11), reason="still works on python 3.10") -async def test_memory_usage_py311(hass: HomeAssistant) -> None: - """Test raise an error on python3.11.""" - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) - - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - assert hass.services.has_service(DOMAIN, SERVICE_MEMORY) - with pytest.raises( - HomeAssistantError, - match="Memory profiling is not supported on Python 3.11. Please use Python 3.10.", - ): - await hass.services.async_call( - DOMAIN, SERVICE_MEMORY, {CONF_SECONDS: 0.000001}, blocking=True - ) - - async def test_object_growth_logging( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: From 99265a983a13473ef04a4d9c97efda268a29ce9a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 16 May 2023 02:33:12 -0500 Subject: [PATCH 368/822] Speed up reconnects by caching state serialize (#93050) --- .../components/websocket_api/commands.py | 122 +++++++++++------- .../components/websocket_api/messages.py | 2 +- homeassistant/core.py | 24 ++++ homeassistant/helpers/json.py | 6 +- tests/components/websocket_api/test_http.py | 7 +- tests/test_core.py | 44 +++++++ 6 files changed, 152 insertions(+), 53 deletions(-) diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index dc6cc84c09c8..dd3f6cdbef06 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -2,7 +2,6 @@ from __future__ import annotations from collections.abc import Callable -from contextlib import suppress import datetime as dt from functools import lru_cache import json @@ -50,6 +49,17 @@ from . import const, decorators, messages from .connection import ActiveConnection from .const import ERR_NOT_FOUND +_STATES_TEMPLATE = "__STATES__" +_STATES_JSON_TEMPLATE = '"__STATES__"' +_HANDLE_SUBSCRIBE_ENTITIES_TEMPLATE = JSON_DUMP( + messages.event_message( + messages.IDEN_TEMPLATE, {messages.ENTITY_EVENT_ADD: _STATES_TEMPLATE} + ) +) +_HANDLE_GET_STATES_TEMPLATE = JSON_DUMP( + messages.result_message(messages.IDEN_TEMPLATE, _STATES_TEMPLATE) +) + @callback def async_register_commands( @@ -242,33 +252,43 @@ def handle_get_states( """Handle get states command.""" states = _async_get_allowed_states(hass, connection) - # JSON serialize here so we can recover if it blows up due to the - # state machine containing unserializable data. This command is required - # to succeed for the UI to show. - response = messages.result_message(msg["id"], states) try: - connection.send_message(JSON_DUMP(response)) - return + serialized_states = [state.as_dict_json() for state in states] except (ValueError, TypeError): - connection.logger.error( - "Unable to serialize to JSON. Bad data found at %s", - format_unserializable_data( - find_paths_unserializable_data(response, dump=JSON_DUMP) - ), - ) - del response + pass + else: + _send_handle_get_states_response(connection, msg["id"], serialized_states) + return # If we can't serialize, we'll filter out unserializable states - serialized = [] + serialized_states = [] for state in states: - # Error is already logged above - with suppress(ValueError, TypeError): - serialized.append(JSON_DUMP(state)) + try: + serialized_states.append(state.as_dict_json()) + except (ValueError, TypeError): + connection.logger.error( + "Unable to serialize to JSON. Bad data found at %s", + format_unserializable_data( + find_paths_unserializable_data(state, dump=JSON_DUMP) + ), + ) - # We now have partially serialized states. Craft some JSON. - response2 = JSON_DUMP(messages.result_message(msg["id"], ["TO_REPLACE"])) - response2 = response2.replace('"TO_REPLACE"', ", ".join(serialized)) - connection.send_message(response2) + _send_handle_get_states_response(connection, msg["id"], serialized_states) + + +def _send_handle_get_states_response( + connection: ActiveConnection, msg_id: int, serialized_states: list[str] +) -> None: + """Send handle get states response.""" + connection.send_message( + _HANDLE_GET_STATES_TEMPLATE.replace( + messages.IDEN_JSON_TEMPLATE, str(msg_id), 1 + ).replace( + _STATES_JSON_TEMPLATE, + "[" + ",".join(serialized_states) + "]", + 1, + ) + ) @callback @@ -304,42 +324,50 @@ def handle_subscribe_entities( EVENT_STATE_CHANGED, forward_entity_changes, run_immediately=True ) connection.send_result(msg["id"]) - data: dict[str, dict[str, dict]] = { - messages.ENTITY_EVENT_ADD: { - state.entity_id: state.as_compressed_state() - for state in states - if not entity_ids or state.entity_id in entity_ids - } - } # JSON serialize here so we can recover if it blows up due to the # state machine containing unserializable data. This command is required # to succeed for the UI to show. - response = messages.event_message(msg["id"], data) try: - connection.send_message(JSON_DUMP(response)) - return + serialized_states = [ + state.as_compressed_state_json() + for state in states + if not entity_ids or state.entity_id in entity_ids + ] except (ValueError, TypeError): - connection.logger.error( - "Unable to serialize to JSON. Bad data found at %s", - format_unserializable_data( - find_paths_unserializable_data(response, dump=JSON_DUMP) - ), - ) - del response + pass + else: + _send_handle_entities_init_response(connection, msg["id"], serialized_states) + return - add_entities = data[messages.ENTITY_EVENT_ADD] - cannot_serialize: list[str] = [] - for entity_id, state_dict in add_entities.items(): + serialized_states = [] + for state in states: try: - JSON_DUMP(state_dict) + serialized_states.append(state.as_compressed_state_json()) except (ValueError, TypeError): - cannot_serialize.append(entity_id) + connection.logger.error( + "Unable to serialize to JSON. Bad data found at %s", + format_unserializable_data( + find_paths_unserializable_data(state, dump=JSON_DUMP) + ), + ) - for entity_id in cannot_serialize: - del add_entities[entity_id] + _send_handle_entities_init_response(connection, msg["id"], serialized_states) - connection.send_message(JSON_DUMP(messages.event_message(msg["id"], data))) + +def _send_handle_entities_init_response( + connection: ActiveConnection, msg_id: int, serialized_states: list[str] +) -> None: + """Send handle entities init response.""" + connection.send_message( + _HANDLE_SUBSCRIBE_ENTITIES_TEMPLATE.replace( + messages.IDEN_JSON_TEMPLATE, str(msg_id), 1 + ).replace( + _STATES_JSON_TEMPLATE, + "{" + ",".join(serialized_states) + "}", + 1, + ) + ) @decorators.websocket_command({vol.Required("type"): "get_services"}) diff --git a/homeassistant/components/websocket_api/messages.py b/homeassistant/components/websocket_api/messages.py index 341aebdf9e49..4fc604df2dcd 100644 --- a/homeassistant/components/websocket_api/messages.py +++ b/homeassistant/components/websocket_api/messages.py @@ -44,7 +44,7 @@ ENTITY_EVENT_REMOVE = "r" ENTITY_EVENT_CHANGE = "c" -def result_message(iden: int, result: Any = None) -> dict[str, Any]: +def result_message(iden: JSON_TYPE | int, result: Any = None) -> dict[str, Any]: """Return a success result message.""" return {"id": iden, "type": const.TYPE_RESULT, "success": True, "result": result} diff --git a/homeassistant/core.py b/homeassistant/core.py index 2fed56644e41..9560d0f10317 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -80,6 +80,7 @@ from .exceptions import ( Unauthorized, ) from .helpers.aiohttp_compat import restore_original_aiohttp_cancel_behavior +from .helpers.json import json_dumps from .util import dt as dt_util, location, ulid as ulid_util from .util.async_ import run_callback_threadsafe, shutdown_run_callback_threadsafe from .util.read_only_dict import ReadOnlyDict @@ -1224,6 +1225,8 @@ class State: "object_id", "_as_dict", "_as_compressed_state", + "_as_dict_json", + "_as_compressed_state_json", ) def __init__( @@ -1260,6 +1263,8 @@ class State: self.domain, self.object_id = split_entity_id(self.entity_id) self._as_dict: ReadOnlyDict[str, Collection[Any]] | None = None self._as_compressed_state: dict[str, Any] | None = None + self._as_dict_json: str | None = None + self._as_compressed_state_json: str | None = None @property def name(self) -> str: @@ -1294,6 +1299,12 @@ class State: ) return self._as_dict + 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 + def as_compressed_state(self) -> dict[str, Any]: """Build a compressed dict of a state for adds. @@ -1321,6 +1332,19 @@ class State: self._as_compressed_state = compressed_state return compressed_state + def as_compressed_state_json(self) -> str: + """Build a compressed JSON key value pair of a state for adds. + + The JSON string is a key value pair of the entity_id and the compressed 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 + @classmethod def from_dict(cls, json_dict: dict[str, Any]) -> Self | None: """Initialize a state from a dict. diff --git a/homeassistant/helpers/json.py b/homeassistant/helpers/json.py index c15436ed2c1e..5f8a6894121b 100644 --- a/homeassistant/helpers/json.py +++ b/homeassistant/helpers/json.py @@ -9,7 +9,6 @@ from typing import Any, Final import orjson -from homeassistant.core import Event, State from homeassistant.util.file import write_utf8_file, write_utf8_file_atomic from homeassistant.util.json import ( # pylint: disable=unused-import # noqa: F401 JSON_DECODE_EXCEPTIONS, @@ -189,6 +188,11 @@ def find_paths_unserializable_data( This method is slow! Only use for error handling. """ + from homeassistant.core import ( # pylint: disable=import-outside-toplevel + Event, + State, + ) + to_process = deque([(bad_data, "$")]) invalid = {} diff --git a/tests/components/websocket_api/test_http.py b/tests/components/websocket_api/test_http.py index 7c008f7515bb..02384aace89d 100644 --- a/tests/components/websocket_api/test_http.py +++ b/tests/components/websocket_api/test_http.py @@ -188,10 +188,9 @@ async def test_non_json_message( assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert msg["result"] == [] - assert ( - f"Unable to serialize to JSON. Bad data found at $.result[0](State: test_domain.entity).attributes.bad={bad_data}(" - in caplog.text - ) + assert "Unable to serialize to JSON. Bad data found" in caplog.text + assert "State: test_domain.entity" in caplog.text + assert "bad= None: assert state.as_dict() is as_dict_1 +def test_state_as_dict_json() -> None: + """Test a State as JSON.""" + last_time = datetime(1984, 12, 8, 12, 0, 0) + state = ha.State( + "happy.happy", + "on", + {"pig": "dog"}, + last_updated=last_time, + last_changed=last_time, + context=ha.Context(id="01H0D6K3RFJAYAV2093ZW30PCW"), + ) + expected = ( + '{"entity_id":"happy.happy","state":"on","attributes":{"pig":"dog"},' + '"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() + 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 + + def test_state_as_compressed_state() -> None: """Test a State as compressed state.""" last_time = datetime(1984, 12, 8, 12, 0, 0, tzinfo=dt_util.UTC) @@ -518,6 +541,27 @@ def test_state_as_compressed_state_unique_last_updated() -> None: assert state.as_compressed_state() is as_compressed_state +def test_state_as_compressed_state_json() -> None: + """Test a State as a JSON compressed state.""" + last_time = datetime(1984, 12, 8, 12, 0, 0, tzinfo=dt_util.UTC) + state = ha.State( + "happy.happy", + "on", + {"pig": "dog"}, + last_updated=last_time, + last_changed=last_time, + 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() + # 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 + + async def test_eventbus_add_remove_listener(hass: HomeAssistant) -> None: """Test remove_listener method.""" old_count = len(hass.bus.async_listeners()) From 32b70ea133ab50e814f3702e6f9a9cd7891aeecb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 16 May 2023 03:26:04 -0500 Subject: [PATCH 369/822] Bump PyJWT to 2.7.0 (#93128) --- homeassistant/auth/jwt_wrapper.py | 4 ++-- homeassistant/helpers/config_entry_oauth2_flow.py | 4 ++-- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/auth/jwt_wrapper.py b/homeassistant/auth/jwt_wrapper.py index 546e4afdcfa6..c681df665571 100644 --- a/homeassistant/auth/jwt_wrapper.py +++ b/homeassistant/auth/jwt_wrapper.py @@ -93,7 +93,7 @@ class _PyJWTWithVerify(PyJWT): # nothing slips through. assert "exp" in payload, "exp claim is required" assert "iat" in payload, "iat claim is required" - self._validate_claims( # type: ignore[no-untyped-call] + self._validate_claims( payload=payload, options=merged_options, issuer=issuer, @@ -102,7 +102,7 @@ class _PyJWTWithVerify(PyJWT): return payload -_jwt = _PyJWTWithVerify() # type: ignore[no-untyped-call] +_jwt = _PyJWTWithVerify() verify_and_decode = _jwt.verify_and_decode unverified_hs256_token_decode = lru_cache(maxsize=JWT_TOKEN_CACHE_SIZE)( partial( diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py index 072fed4220bf..fe4e5473092d 100644 --- a/homeassistant/helpers/config_entry_oauth2_flow.py +++ b/homeassistant/helpers/config_entry_oauth2_flow.py @@ -542,7 +542,7 @@ def _encode_jwt(hass: HomeAssistant, data: dict) -> str: @callback -def _decode_jwt(hass: HomeAssistant, encoded: str) -> dict | None: +def _decode_jwt(hass: HomeAssistant, encoded: str) -> dict[str, Any] | None: """JWT encode data.""" secret: str | None = hass.data.get(DATA_JWT_SECRET) @@ -550,6 +550,6 @@ def _decode_jwt(hass: HomeAssistant, encoded: str) -> dict | None: return None try: - return jwt.decode(encoded, secret, algorithms=["HS256"]) + return jwt.decode(encoded, secret, algorithms=["HS256"]) # type: ignore[no-any-return] except jwt.InvalidTokenError: return None diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ae2b0fef90b1..de886306e5a6 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,4 +1,4 @@ -PyJWT==2.6.0 +PyJWT==2.7.0 PyNaCl==1.5.0 PyTurboJPEG==1.6.7 aiodiscover==1.4.16 diff --git a/pyproject.toml b/pyproject.toml index 11d0f910067d..6da5817c4bc2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ dependencies = [ "ifaddr==0.1.7", "jinja2==3.1.2", "lru-dict==1.1.8", - "PyJWT==2.6.0", + "PyJWT==2.7.0", # PyJWT has loose dependency. We want the latest one. "cryptography==40.0.2", # pyOpenSSL 23.1.0 is required to work with cryptography 39+ diff --git a/requirements.txt b/requirements.txt index 5931440dccb3..7efb44738afe 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,7 +15,7 @@ home-assistant-bluetooth==1.10.0 ifaddr==0.1.7 jinja2==3.1.2 lru-dict==1.1.8 -PyJWT==2.6.0 +PyJWT==2.7.0 cryptography==40.0.2 pyOpenSSL==23.1.0 orjson==3.8.12 From 9bd49e4f1d0cb9d98c7c8dfc102427fec18fda92 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 16 May 2023 11:13:14 +0200 Subject: [PATCH 370/822] Fix lingering timer in usgs_earthquakes_feed (#93084) --- .../components/usgs_earthquakes_feed/geo_location.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/usgs_earthquakes_feed/geo_location.py b/homeassistant/components/usgs_earthquakes_feed/geo_location.py index 99aecfc406b5..1c6c1b04231f 100644 --- a/homeassistant/components/usgs_earthquakes_feed/geo_location.py +++ b/homeassistant/components/usgs_earthquakes_feed/geo_location.py @@ -165,7 +165,9 @@ class UsgsEarthquakesFeedEntityManager: await self.async_update() # Trigger updates at regular intervals. - async_track_time_interval(self._hass, update, self._scan_interval) + async_track_time_interval( + self._hass, update, self._scan_interval, cancel_on_shutdown=True + ) _LOGGER.debug("Feed entity manager initialized") async def async_update(self) -> None: From e5c12122e2830fb7ba411b4fb98a80b07b3eee0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Tue, 16 May 2023 12:51:22 +0200 Subject: [PATCH 371/822] Update aioairzone to v0.5.6 (#93146) --- homeassistant/components/airzone/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/airzone/test_config_flow.py | 15 +++++++++++++++ tests/components/airzone/test_coordinator.py | 5 ++++- tests/components/airzone/test_diagnostics.py | 18 +++++++++++++++++- tests/components/airzone/test_init.py | 8 +++++++- tests/components/airzone/util.py | 3 +++ 8 files changed, 49 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/airzone/manifest.json b/homeassistant/components/airzone/manifest.json index 991584dd8f8a..d55ffd187fd8 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.5.5"] + "requirements": ["aioairzone==0.5.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6357efbf5a08..fd8cdbdcdd09 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -116,7 +116,7 @@ aio_georss_gdacs==0.8 aioairq==0.2.4 # homeassistant.components.airzone -aioairzone==0.5.5 +aioairzone==0.5.6 # homeassistant.components.ambient_station aioambient==2023.04.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 25f47998dce3..8e9205bae67d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -106,7 +106,7 @@ aio_georss_gdacs==0.8 aioairq==0.2.4 # homeassistant.components.airzone -aioairzone==0.5.5 +aioairzone==0.5.6 # homeassistant.components.ambient_station aioambient==2023.04.0 diff --git a/tests/components/airzone/test_config_flow.py b/tests/components/airzone/test_config_flow.py index 2d89d0b556e8..5460272e74e9 100644 --- a/tests/components/airzone/test_config_flow.py +++ b/tests/components/airzone/test_config_flow.py @@ -45,6 +45,9 @@ async def test_form(hass: HomeAssistant) -> None: ), patch( "homeassistant.components.airzone.AirzoneLocalApi.get_hvac_systems", side_effect=SystemOutOfRange, + ), patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_version", + return_value=HVAC_VERSION_MOCK, ), patch( "homeassistant.components.airzone.AirzoneLocalApi.get_webserver", return_value=HVAC_WEBSERVER_MOCK, @@ -88,6 +91,9 @@ async def test_form_invalid_system_id(hass: HomeAssistant) -> None: ) as mock_hvac, patch( "homeassistant.components.airzone.AirzoneLocalApi.get_hvac_systems", side_effect=SystemOutOfRange, + ), patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_version", + return_value=HVAC_VERSION_MOCK, ), patch( "homeassistant.components.airzone.AirzoneLocalApi.get_webserver", side_effect=InvalidMethod, @@ -184,6 +190,9 @@ async def test_dhcp_flow(hass: HomeAssistant) -> None: ), patch( "homeassistant.components.airzone.AirzoneLocalApi.get_hvac_systems", side_effect=SystemOutOfRange, + ), patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_version", + return_value=HVAC_VERSION_MOCK, ), patch( "homeassistant.components.airzone.AirzoneLocalApi.get_webserver", return_value=HVAC_WEBSERVER_MOCK, @@ -259,6 +268,9 @@ async def test_dhcp_connection_error(hass: HomeAssistant) -> None: ), patch( "homeassistant.components.airzone.AirzoneLocalApi.get_hvac_systems", side_effect=SystemOutOfRange, + ), patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_version", + return_value=HVAC_VERSION_MOCK, ), patch( "homeassistant.components.airzone.AirzoneLocalApi.get_webserver", return_value=HVAC_WEBSERVER_MOCK, @@ -309,6 +321,9 @@ async def test_dhcp_invalid_system_id(hass: HomeAssistant) -> None: ) as mock_hvac, patch( "homeassistant.components.airzone.AirzoneLocalApi.get_hvac_systems", side_effect=SystemOutOfRange, + ), patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_version", + return_value=HVAC_VERSION_MOCK, ), patch( "homeassistant.components.airzone.AirzoneLocalApi.get_webserver", side_effect=InvalidMethod, diff --git a/tests/components/airzone/test_coordinator.py b/tests/components/airzone/test_coordinator.py index c2ee5c0cbca6..bcfdad8ead87 100644 --- a/tests/components/airzone/test_coordinator.py +++ b/tests/components/airzone/test_coordinator.py @@ -10,7 +10,7 @@ from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.util.dt import utcnow -from .util import CONFIG, HVAC_MOCK +from .util import CONFIG, HVAC_MOCK, HVAC_VERSION_MOCK from tests.common import MockConfigEntry, async_fire_time_changed @@ -31,6 +31,9 @@ async def test_coordinator_client_connector_error(hass: HomeAssistant) -> None: ) as mock_hvac, patch( "homeassistant.components.airzone.AirzoneLocalApi.get_hvac_systems", side_effect=SystemOutOfRange, + ), patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_version", + return_value=HVAC_VERSION_MOCK, ), patch( "homeassistant.components.airzone.AirzoneLocalApi.get_webserver", side_effect=InvalidMethod, diff --git a/tests/components/airzone/test_diagnostics.py b/tests/components/airzone/test_diagnostics.py index 34a563b7af84..2fa5a7d566a2 100644 --- a/tests/components/airzone/test_diagnostics.py +++ b/tests/components/airzone/test_diagnostics.py @@ -7,6 +7,7 @@ from aioairzone.const import ( API_MAC, API_SYSTEM_ID, API_SYSTEMS, + API_VERSION, API_WIFI_RSSI, AZD_ID, AZD_MASTER, @@ -15,6 +16,7 @@ from aioairzone.const import ( AZD_ZONES, AZD_ZONES_NUM, RAW_HVAC, + RAW_VERSION, RAW_WEBSERVER, ) @@ -23,7 +25,13 @@ from homeassistant.components.diagnostics import REDACTED from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant -from .util import CONFIG, HVAC_MOCK, HVAC_WEBSERVER_MOCK, async_init_integration +from .util import ( + CONFIG, + HVAC_MOCK, + HVAC_VERSION_MOCK, + HVAC_WEBSERVER_MOCK, + async_init_integration, +) from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator @@ -41,6 +49,7 @@ async def test_config_entry_diagnostics( "homeassistant.components.airzone.AirzoneLocalApi.raw_data", return_value={ RAW_HVAC: HVAC_MOCK, + RAW_VERSION: HVAC_VERSION_MOCK, RAW_WEBSERVER: HVAC_WEBSERVER_MOCK, }, ): @@ -53,6 +62,13 @@ async def test_config_entry_diagnostics( }.items() ) + assert ( + diag["api_data"][RAW_VERSION].items() + >= { + API_VERSION: HVAC_VERSION_MOCK[API_VERSION], + }.items() + ) + assert ( diag["api_data"][RAW_WEBSERVER].items() >= { diff --git a/tests/components/airzone/test_init.py b/tests/components/airzone/test_init.py index 274ceb21d513..bb7cb06d1c24 100644 --- a/tests/components/airzone/test_init.py +++ b/tests/components/airzone/test_init.py @@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .util import CONFIG, HVAC_MOCK, HVAC_WEBSERVER_MOCK +from .util import CONFIG, HVAC_MOCK, HVAC_VERSION_MOCK, HVAC_WEBSERVER_MOCK from tests.common import MockConfigEntry @@ -28,6 +28,9 @@ async def test_unique_id_migrate(hass: HomeAssistant) -> None: ), patch( "homeassistant.components.airzone.AirzoneLocalApi.get_hvac_systems", side_effect=SystemOutOfRange, + ), patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_version", + return_value=HVAC_VERSION_MOCK, ), patch( "homeassistant.components.airzone.AirzoneLocalApi.get_webserver", side_effect=InvalidMethod, @@ -47,6 +50,9 @@ async def test_unique_id_migrate(hass: HomeAssistant) -> None: ), patch( "homeassistant.components.airzone.AirzoneLocalApi.get_hvac_systems", side_effect=SystemOutOfRange, + ), patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_version", + return_value=HVAC_VERSION_MOCK, ), patch( "homeassistant.components.airzone.AirzoneLocalApi.get_webserver", return_value=HVAC_WEBSERVER_MOCK, diff --git a/tests/components/airzone/util.py b/tests/components/airzone/util.py index 1f7bbd5bd88f..4afcaeac2329 100644 --- a/tests/components/airzone/util.py +++ b/tests/components/airzone/util.py @@ -306,6 +306,9 @@ async def async_init_integration( ), patch( "homeassistant.components.airzone.AirzoneLocalApi.get_hvac_systems", return_value=HVAC_SYSTEMS_MOCK, + ), patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_version", + return_value=HVAC_VERSION_MOCK, ), patch( "homeassistant.components.airzone.AirzoneLocalApi.get_webserver", return_value=HVAC_WEBSERVER_MOCK, From 685016052b6b37cc32f0ff4748a5e36d7dfa0ecd Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 16 May 2023 16:38:17 +0000 Subject: [PATCH 372/822] Bump `accuweather` to version 0.5.2 (#93130) --- homeassistant/components/accuweather/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/accuweather/manifest.json b/homeassistant/components/accuweather/manifest.json index ad07154ff6b9..fbf31720e13e 100644 --- a/homeassistant/components/accuweather/manifest.json +++ b/homeassistant/components/accuweather/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["accuweather"], "quality_scale": "platinum", - "requirements": ["accuweather==0.5.1"] + "requirements": ["accuweather==0.5.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index fd8cdbdcdd09..fce977dc2add 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -71,7 +71,7 @@ WSDiscovery==2.0.0 WazeRouteCalculator==0.14 # homeassistant.components.accuweather -accuweather==0.5.1 +accuweather==0.5.2 # homeassistant.components.adax adax==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8e9205bae67d..4eb16a7841bc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -61,7 +61,7 @@ WSDiscovery==2.0.0 WazeRouteCalculator==0.14 # homeassistant.components.accuweather -accuweather==0.5.1 +accuweather==0.5.2 # homeassistant.components.adax adax==0.2.0 From e949344dd95ae052d8fd5fa25abc205c1652efb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Tue, 16 May 2023 20:34:03 +0300 Subject: [PATCH 373/822] Huawei LTE connection management cleanups (#85929) * Disconnect rather than just logout at end of config flow Neither the connection or the requests session will be reused, so there's no reason just to logout. Do all of the connection closure so we get all of huawei-lte-api's cleanups explicitly done. * Name connect functions consistently, analoguous to disconnect --- homeassistant/components/huawei_lte/__init__.py | 4 ++-- .../components/huawei_lte/config_flow.py | 16 ++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index 5e5b2c8dc946..95197dcbb49f 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -326,7 +326,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Huawei LTE component from config entry.""" url = entry.data[CONF_URL] - def get_connection() -> Connection: + def _connect() -> Connection: """Set up a connection.""" if entry.options.get(CONF_UNAUTHENTICATED_MODE): _LOGGER.debug("Connecting in unauthenticated mode, reduced feature set") @@ -341,7 +341,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return connection try: - connection = await hass.async_add_executor_job(get_connection) + connection = await hass.async_add_executor_job(_connect) except LoginErrorInvalidCredentialsException as ex: raise ConfigEntryAuthFailed from ex except Timeout as ex: diff --git a/homeassistant/components/huawei_lte/config_flow.py b/homeassistant/components/huawei_lte/config_flow.py index d4aa170f70d4..f6c3b69ddeb6 100644 --- a/homeassistant/components/huawei_lte/config_flow.py +++ b/homeassistant/components/huawei_lte/config_flow.py @@ -111,7 +111,7 @@ class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors=errors or {}, ) - async def _try_connect( + async def _connect( self, user_input: dict[str, Any], errors: dict[str, str] ) -> Connection | None: """Try connecting with given data.""" @@ -149,11 +149,11 @@ class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return conn @staticmethod - def _logout(conn: Connection) -> None: + def _disconnect(conn: Connection) -> None: try: - conn.user_session.user.logout() # type: ignore[union-attr] + conn.close() except Exception: # pylint: disable=broad-except - _LOGGER.debug("Could not logout", exc_info=True) + _LOGGER.debug("Disconnect error", exc_info=True) async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -197,7 +197,7 @@ class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): wlan_settings = {} return device_info, wlan_settings - conn = await self._try_connect(user_input, errors) + conn = await self._connect(user_input, errors) if errors: return await self._async_show_user_form( user_input=user_input, errors=errors @@ -207,7 +207,7 @@ class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): info, wlan_settings = await self.hass.async_add_executor_job( get_device_info, conn ) - await self.hass.async_add_executor_job(self._logout, conn) + await self.hass.async_add_executor_job(self._disconnect, conn) user_input.update( { @@ -298,9 +298,9 @@ class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): new_data = {**entry.data, **user_input} errors: dict[str, str] = {} - conn = await self._try_connect(new_data, errors) + conn = await self._connect(new_data, errors) if conn: - await self.hass.async_add_executor_job(self._logout, conn) + await self.hass.async_add_executor_job(self._disconnect, conn) if errors: return await self._async_show_reauth_form( user_input=user_input, errors=errors From bf16f6b104793b730c3d3441ae4cebcd5f24d748 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 16 May 2023 12:39:16 -0500 Subject: [PATCH 374/822] Remove as_compressed_state cache (#93169) * Remove as_compressed_state cache All calls to as_compressed_state are now covered by a higher level JSON cache so there is no need to store these in memory anymore * Remove as_compressed_state cache All calls to as_compressed_state are now covered by a higher level JSON cache so there is no need to store these in memory anymore --- homeassistant/core.py | 5 ----- tests/test_core.py | 2 -- 2 files changed, 7 deletions(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index 9560d0f10317..2806907f19f1 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -1224,7 +1224,6 @@ class State: "domain", "object_id", "_as_dict", - "_as_compressed_state", "_as_dict_json", "_as_compressed_state_json", ) @@ -1262,7 +1261,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_compressed_state: dict[str, Any] | None = None self._as_dict_json: str | None = None self._as_compressed_state_json: str | None = None @@ -1312,8 +1310,6 @@ class State: Sends c (context) as a string if it only contains an id. """ - if self._as_compressed_state: - return self._as_compressed_state state_context = self.context if state_context.parent_id is None and state_context.user_id is None: context: dict[str, Any] | str = state_context.id @@ -1329,7 +1325,6 @@ class State: compressed_state[COMPRESSED_STATE_LAST_UPDATED] = dt_util.utc_to_timestamp( self.last_updated ) - self._as_compressed_state = compressed_state return compressed_state def as_compressed_state_json(self) -> str: diff --git a/tests/test_core.py b/tests/test_core.py index f9fdf6284c02..5cb92ffe5c92 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -511,7 +511,6 @@ def test_state_as_compressed_state() -> None: assert as_compressed_state == expected # 2nd time to verify cache assert state.as_compressed_state() == expected - assert state.as_compressed_state() is as_compressed_state def test_state_as_compressed_state_unique_last_updated() -> None: @@ -538,7 +537,6 @@ def test_state_as_compressed_state_unique_last_updated() -> None: assert as_compressed_state == expected # 2nd time to verify cache assert state.as_compressed_state() == expected - assert state.as_compressed_state() is as_compressed_state def test_state_as_compressed_state_json() -> None: From e48961d29bfc30bd593ea8c7025eeaabf31920c2 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Tue, 16 May 2023 16:28:20 -0600 Subject: [PATCH 375/822] Bump `regenmaschine` to 2023.05.1 (#93139) --- homeassistant/components/rainmachine/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/rainmachine/manifest.json b/homeassistant/components/rainmachine/manifest.json index ff35b24cc97f..574ca3d7f43b 100644 --- a/homeassistant/components/rainmachine/manifest.json +++ b/homeassistant/components/rainmachine/manifest.json @@ -10,7 +10,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["regenmaschine"], - "requirements": ["regenmaschine==2022.11.0"], + "requirements": ["regenmaschine==2023.05.1"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index fce977dc2add..3b03bfec716d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2236,7 +2236,7 @@ rapt-ble==0.1.0 raspyrfm-client==1.2.8 # homeassistant.components.rainmachine -regenmaschine==2022.11.0 +regenmaschine==2023.05.1 # homeassistant.components.renault renault-api==0.1.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4eb16a7841bc..17b929d828d4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1617,7 +1617,7 @@ radiotherm==2.1.0 rapt-ble==0.1.0 # homeassistant.components.rainmachine -regenmaschine==2022.11.0 +regenmaschine==2023.05.1 # homeassistant.components.renault renault-api==0.1.13 From 210c309770252c5036314376245b5ff576c6fb81 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 16 May 2023 18:52:18 -0500 Subject: [PATCH 376/822] Bump pyatv to 0.11.0 (#93172) --- homeassistant/components/apple_tv/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/apple_tv/manifest.json b/homeassistant/components/apple_tv/manifest.json index aa73bcc7ba54..c534c635317c 100644 --- a/homeassistant/components/apple_tv/manifest.json +++ b/homeassistant/components/apple_tv/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/apple_tv", "iot_class": "local_push", "loggers": ["pyatv", "srptools"], - "requirements": ["pyatv==0.10.3"], + "requirements": ["pyatv==0.11.0"], "zeroconf": [ "_mediaremotetv._tcp.local.", "_companion-link._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 3b03bfec716d..004b3ef981f8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1512,7 +1512,7 @@ pyatmo==7.5.0 pyatome==0.1.1 # homeassistant.components.apple_tv -pyatv==0.10.3 +pyatv==0.11.0 # homeassistant.components.aussie_broadband pyaussiebb==0.0.15 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 17b929d828d4..07e64b2de3d6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1124,7 +1124,7 @@ pyatag==0.3.5.3 pyatmo==7.5.0 # homeassistant.components.apple_tv -pyatv==0.10.3 +pyatv==0.11.0 # homeassistant.components.aussie_broadband pyaussiebb==0.0.15 From defc23cc7c5144f9653f68f2c3c0b7dc7ec74eb0 Mon Sep 17 00:00:00 2001 From: rikroe <42204099+rikroe@users.noreply.github.com> Date: Wed, 17 May 2023 02:03:01 +0200 Subject: [PATCH 377/822] Fix china login for bmw_connected_drive (#93180) * Bump bimmer_connected to 0.13.5 * Fix snapshots after dependency bump * Load gcid from config entry if available * Add tests --------- Co-authored-by: rikroe --- .../components/bmw_connected_drive/config_flow.py | 5 ++++- homeassistant/components/bmw_connected_drive/const.py | 1 + .../components/bmw_connected_drive/coordinator.py | 7 +++++-- .../components/bmw_connected_drive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/bmw_connected_drive/__init__.py | 3 +++ .../bmw_connected_drive/snapshots/test_diagnostics.ambr | 3 +++ tests/components/bmw_connected_drive/test_config_flow.py | 8 +++++++- 9 files changed, 26 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/bmw_connected_drive/config_flow.py b/homeassistant/components/bmw_connected_drive/config_flow.py index eb58a6c7f13f..926706397a68 100644 --- a/homeassistant/components/bmw_connected_drive/config_flow.py +++ b/homeassistant/components/bmw_connected_drive/config_flow.py @@ -16,7 +16,7 @@ from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from . import DOMAIN -from .const import CONF_ALLOWED_REGIONS, CONF_READ_ONLY, CONF_REFRESH_TOKEN +from .const import CONF_ALLOWED_REGIONS, CONF_GCID, CONF_READ_ONLY, CONF_REFRESH_TOKEN DATA_SCHEMA = vol.Schema( { @@ -51,6 +51,8 @@ async def validate_input( retval = {"title": f"{data[CONF_USERNAME]}{data.get(CONF_SOURCE, '')}"} if auth.refresh_token: retval[CONF_REFRESH_TOKEN] = auth.refresh_token + if auth.gcid: + retval[CONF_GCID] = auth.gcid return retval @@ -80,6 +82,7 @@ class BMWConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): entry_data = { **user_input, CONF_REFRESH_TOKEN: info.get(CONF_REFRESH_TOKEN), + CONF_GCID: info.get(CONF_GCID), } except CannotConnect: errors["base"] = "cannot_connect" diff --git a/homeassistant/components/bmw_connected_drive/const.py b/homeassistant/components/bmw_connected_drive/const.py index 50634ebdb969..37225fc052fb 100644 --- a/homeassistant/components/bmw_connected_drive/const.py +++ b/homeassistant/components/bmw_connected_drive/const.py @@ -11,6 +11,7 @@ CONF_ALLOWED_REGIONS = ["china", "north_america", "rest_of_world"] CONF_READ_ONLY = "read_only" CONF_ACCOUNT = "account" CONF_REFRESH_TOKEN = "refresh_token" +CONF_GCID = "gcid" DATA_HASS_CONFIG = "hass_config" diff --git a/homeassistant/components/bmw_connected_drive/coordinator.py b/homeassistant/components/bmw_connected_drive/coordinator.py index 9c3e15a808b7..f6354422312d 100644 --- a/homeassistant/components/bmw_connected_drive/coordinator.py +++ b/homeassistant/components/bmw_connected_drive/coordinator.py @@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_READ_ONLY, CONF_REFRESH_TOKEN, DOMAIN +from .const import CONF_GCID, CONF_READ_ONLY, CONF_REFRESH_TOKEN, DOMAIN DEFAULT_SCAN_INTERVAL_SECONDS = 300 SCAN_INTERVAL = timedelta(seconds=DEFAULT_SCAN_INTERVAL_SECONDS) @@ -41,7 +41,10 @@ class BMWDataUpdateCoordinator(DataUpdateCoordinator[None]): self._entry = entry if CONF_REFRESH_TOKEN in entry.data: - self.account.set_refresh_token(entry.data[CONF_REFRESH_TOKEN]) + self.account.set_refresh_token( + refresh_token=entry.data[CONF_REFRESH_TOKEN], + gcid=entry.data.get(CONF_GCID), + ) super().__init__( hass, diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index afabcbd3df4a..c600a1529a9c 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", "iot_class": "cloud_polling", "loggers": ["bimmer_connected"], - "requirements": ["bimmer_connected==0.13.3"] + "requirements": ["bimmer_connected==0.13.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 004b3ef981f8..ce8f8ae767a4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -431,7 +431,7 @@ beautifulsoup4==4.11.1 bellows==0.35.5 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.13.3 +bimmer_connected==0.13.5 # homeassistant.components.bizkaibus bizkaibus==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 07e64b2de3d6..fe1e3107c124 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -364,7 +364,7 @@ beautifulsoup4==4.11.1 bellows==0.35.5 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.13.3 +bimmer_connected==0.13.5 # homeassistant.components.bluetooth bleak-retry-connector==3.0.2 diff --git a/tests/components/bmw_connected_drive/__init__.py b/tests/components/bmw_connected_drive/__init__.py index 12957db5cac2..b1f1db305b89 100644 --- a/tests/components/bmw_connected_drive/__init__.py +++ b/tests/components/bmw_connected_drive/__init__.py @@ -13,6 +13,7 @@ import respx from homeassistant import config_entries from homeassistant.components.bmw_connected_drive.const import ( + CONF_GCID, CONF_READ_ONLY, CONF_REFRESH_TOKEN, DOMAIN as BMW_DOMAIN, @@ -33,6 +34,7 @@ FIXTURE_USER_INPUT = { CONF_REGION: "rest_of_world", } FIXTURE_REFRESH_TOKEN = "SOME_REFRESH_TOKEN" +FIXTURE_GCID = "SOME_GCID" FIXTURE_CONFIG_ENTRY = { "entry_id": "1", @@ -43,6 +45,7 @@ FIXTURE_CONFIG_ENTRY = { CONF_PASSWORD: FIXTURE_USER_INPUT[CONF_PASSWORD], CONF_REGION: FIXTURE_USER_INPUT[CONF_REGION], CONF_REFRESH_TOKEN: FIXTURE_REFRESH_TOKEN, + CONF_GCID: FIXTURE_GCID, }, "options": {CONF_READ_ONLY: False}, "source": config_entries.SOURCE_USER, diff --git a/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr b/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr index 7ee3f6259114..f5966afb32eb 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr @@ -2357,6 +2357,7 @@ }), ]), 'info': dict({ + 'gcid': 'SOME_GCID', 'password': '**REDACTED**', 'refresh_token': '**REDACTED**', 'region': 'rest_of_world', @@ -3860,6 +3861,7 @@ }), ]), 'info': dict({ + 'gcid': 'SOME_GCID', 'password': '**REDACTED**', 'refresh_token': '**REDACTED**', 'region': 'rest_of_world', @@ -4692,6 +4694,7 @@ }), ]), 'info': dict({ + 'gcid': 'SOME_GCID', 'password': '**REDACTED**', 'refresh_token': '**REDACTED**', 'region': 'rest_of_world', diff --git a/tests/components/bmw_connected_drive/test_config_flow.py b/tests/components/bmw_connected_drive/test_config_flow.py index e471a61d027c..3540df851e9d 100644 --- a/tests/components/bmw_connected_drive/test_config_flow.py +++ b/tests/components/bmw_connected_drive/test_config_flow.py @@ -15,7 +15,12 @@ from homeassistant.components.bmw_connected_drive.const import ( from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from . import FIXTURE_CONFIG_ENTRY, FIXTURE_REFRESH_TOKEN, FIXTURE_USER_INPUT +from . import ( + FIXTURE_CONFIG_ENTRY, + FIXTURE_GCID, + FIXTURE_REFRESH_TOKEN, + FIXTURE_USER_INPUT, +) from tests.common import MockConfigEntry @@ -26,6 +31,7 @@ FIXTURE_IMPORT_ENTRY = {**FIXTURE_USER_INPUT, CONF_REFRESH_TOKEN: None} def login_sideeffect(self: MyBMWAuthentication): """Mock logging in and setting a refresh token.""" self.refresh_token = FIXTURE_REFRESH_TOKEN + self.gcid = FIXTURE_GCID async def test_show_form(hass: HomeAssistant) -> None: From 6c56ceead07be84a7951ce8824941f4aa0d0b16a Mon Sep 17 00:00:00 2001 From: tronikos Date: Tue, 16 May 2023 18:05:56 -0700 Subject: [PATCH 378/822] Create Google Generative AI Conversation integration (#93156) * Copy openai_conversation to google_generative_ai_conversation This is to improve diff of the next commit with the actual implementation. Commands used: cp -r homeassistant/components/openai_conversation/ homeassistant/components/google_generative_ai_conversation/ cp -r tests/components/openai_conversation/ tests/components/google_generative_ai_conversation/ find homeassistant/components/google_generative_ai_conversation/ tests/components/google_generative_ai_conversation/ -type f | xargs sed -i \ -e 's@openai_conversation@google_generative_ai_conversation@g' \ -e 's@OpenAI Conversation@Google Generative AI Conversation@g' \ -e 's@balloob@tronikos@g' * Add google_generative_ai_conversation to Google brand * Google Generative AI Conversation * Sync recent openai changes * handle empty responses --- CODEOWNERS | 2 + homeassistant/brands/google.json | 1 + .../__init__.py | 157 +++++++++++++++++ .../config_flow.py | 165 ++++++++++++++++++ .../const.py | 33 ++++ .../manifest.json | 11 ++ .../strings.json | 29 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + .../__init__.py | 1 + .../conftest.py | 31 ++++ .../snapshots/test_init.ambr | 33 ++++ .../test_config_flow.py | 118 +++++++++++++ .../test_init.py | 137 +++++++++++++++ 16 files changed, 731 insertions(+) create mode 100644 homeassistant/components/google_generative_ai_conversation/__init__.py create mode 100644 homeassistant/components/google_generative_ai_conversation/config_flow.py create mode 100644 homeassistant/components/google_generative_ai_conversation/const.py create mode 100644 homeassistant/components/google_generative_ai_conversation/manifest.json create mode 100644 homeassistant/components/google_generative_ai_conversation/strings.json create mode 100644 tests/components/google_generative_ai_conversation/__init__.py create mode 100644 tests/components/google_generative_ai_conversation/conftest.py create mode 100644 tests/components/google_generative_ai_conversation/snapshots/test_init.ambr create mode 100644 tests/components/google_generative_ai_conversation/test_config_flow.py create mode 100644 tests/components/google_generative_ai_conversation/test_init.py diff --git a/CODEOWNERS b/CODEOWNERS index 5aef36e2b950..c2eba3864204 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -451,6 +451,8 @@ build.json @home-assistant/supervisor /homeassistant/components/google_assistant_sdk/ @tronikos /tests/components/google_assistant_sdk/ @tronikos /homeassistant/components/google_cloud/ @lufton +/homeassistant/components/google_generative_ai_conversation/ @tronikos +/tests/components/google_generative_ai_conversation/ @tronikos /homeassistant/components/google_mail/ @tkdrob /tests/components/google_mail/ @tkdrob /homeassistant/components/google_sheets/ @tkdrob diff --git a/homeassistant/brands/google.json b/homeassistant/brands/google.json index 0d396ca05ed3..3eb2e9e64f01 100644 --- a/homeassistant/brands/google.json +++ b/homeassistant/brands/google.json @@ -6,6 +6,7 @@ "google_assistant_sdk", "google_cloud", "google_domains", + "google_generative_ai_conversation", "google_mail", "google_maps", "google_pubsub", diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py new file mode 100644 index 000000000000..3d0fac634207 --- /dev/null +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -0,0 +1,157 @@ +"""The Google Generative AI Conversation integration.""" +from __future__ import annotations + +from functools import partial +import logging +from typing import Literal + +from google.api_core.exceptions import ClientError +import google.generativeai as palm +from google.generativeai.types.discuss_types import ChatResponse + +from homeassistant.components import conversation +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, MATCH_ALL +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady, TemplateError +from homeassistant.helpers import intent, template +from homeassistant.util import ulid + +from .const import ( + CONF_CHAT_MODEL, + CONF_PROMPT, + CONF_TEMPERATURE, + CONF_TOP_K, + CONF_TOP_P, + DEFAULT_CHAT_MODEL, + DEFAULT_PROMPT, + DEFAULT_TEMPERATURE, + DEFAULT_TOP_K, + DEFAULT_TOP_P, +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Google Generative AI Conversation from a config entry.""" + palm.configure(api_key=entry.data[CONF_API_KEY]) + + try: + await hass.async_add_executor_job( + partial( + palm.get_model, entry.options.get(CONF_CHAT_MODEL, DEFAULT_CHAT_MODEL) + ) + ) + except ClientError as err: + if err.reason == "API_KEY_INVALID": + _LOGGER.error("Invalid API key: %s", err) + return False + raise ConfigEntryNotReady(err) from err + + conversation.async_set_agent(hass, entry, GoogleGenerativeAIAgent(hass, entry)) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload GoogleGenerativeAI.""" + palm.configure(api_key=None) + conversation.async_unset_agent(hass, entry) + return True + + +class GoogleGenerativeAIAgent(conversation.AbstractConversationAgent): + """Google Generative AI conversation agent.""" + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize the agent.""" + self.hass = hass + self.entry = entry + self.history: dict[str, list[dict]] = {} + + @property + def attribution(self): + """Return the attribution.""" + return { + "name": "Powered by Google Generative AI", + "url": "https://developers.generativeai.google/", + } + + @property + def supported_languages(self) -> list[str] | Literal["*"]: + """Return a list of supported languages.""" + return MATCH_ALL + + async def async_process( + self, user_input: conversation.ConversationInput + ) -> conversation.ConversationResult: + """Process a sentence.""" + raw_prompt = self.entry.options.get(CONF_PROMPT, DEFAULT_PROMPT) + model = self.entry.options.get(CONF_CHAT_MODEL, DEFAULT_CHAT_MODEL) + temperature = self.entry.options.get(CONF_TEMPERATURE, DEFAULT_TEMPERATURE) + top_p = self.entry.options.get(CONF_TOP_P, DEFAULT_TOP_P) + top_k = self.entry.options.get(CONF_TOP_K, DEFAULT_TOP_K) + + if user_input.conversation_id in self.history: + conversation_id = user_input.conversation_id + messages = self.history[conversation_id] + else: + conversation_id = ulid.ulid() + messages = [] + + try: + prompt = self._async_generate_prompt(raw_prompt) + except TemplateError as err: + _LOGGER.error("Error rendering prompt: %s", err) + intent_response = intent.IntentResponse(language=user_input.language) + intent_response.async_set_error( + intent.IntentResponseErrorCode.UNKNOWN, + f"Sorry, I had a problem with my template: {err}", + ) + return conversation.ConversationResult( + response=intent_response, conversation_id=conversation_id + ) + + messages.append({"author": "0", "content": user_input.text}) + + _LOGGER.debug("Prompt for %s: %s", model, messages) + + try: + chat_response: ChatResponse = await palm.chat_async( + model=model, + context=prompt, + messages=messages, + temperature=temperature, + top_p=top_p, + top_k=top_k, + ) + except ClientError as err: + intent_response = intent.IntentResponse(language=user_input.language) + intent_response.async_set_error( + intent.IntentResponseErrorCode.UNKNOWN, + f"Sorry, I had a problem talking to Google Generative AI: {err}", + ) + return conversation.ConversationResult( + response=intent_response, conversation_id=conversation_id + ) + + _LOGGER.debug("Response %s", chat_response) + # For some queries the response is empty. In that case don't update history to avoid + # "google.generativeai.types.discuss_types.AuthorError: Authors are not strictly alternating" + if chat_response.last: + self.history[conversation_id] = chat_response.messages + + intent_response = intent.IntentResponse(language=user_input.language) + intent_response.async_set_speech(chat_response.last) + return conversation.ConversationResult( + response=intent_response, conversation_id=conversation_id + ) + + def _async_generate_prompt(self, raw_prompt: str) -> str: + """Generate a prompt for the user.""" + return template.Template(raw_prompt, self.hass).async_render( + { + "ha_name": self.hass.config.location_name, + }, + parse_result=False, + ) diff --git a/homeassistant/components/google_generative_ai_conversation/config_flow.py b/homeassistant/components/google_generative_ai_conversation/config_flow.py new file mode 100644 index 000000000000..94639177a42f --- /dev/null +++ b/homeassistant/components/google_generative_ai_conversation/config_flow.py @@ -0,0 +1,165 @@ +"""Config flow for Google Generative AI Conversation integration.""" +from __future__ import annotations + +from functools import partial +import logging +import types +from types import MappingProxyType +from typing import Any + +from google.api_core.exceptions import ClientError +import google.generativeai as palm +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.selector import ( + NumberSelector, + NumberSelectorConfig, + TemplateSelector, +) + +from .const import ( + CONF_CHAT_MODEL, + CONF_PROMPT, + CONF_TEMPERATURE, + CONF_TOP_K, + CONF_TOP_P, + DEFAULT_CHAT_MODEL, + DEFAULT_PROMPT, + DEFAULT_TEMPERATURE, + DEFAULT_TOP_K, + DEFAULT_TOP_P, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_API_KEY): str, + } +) + +DEFAULT_OPTIONS = types.MappingProxyType( + { + CONF_PROMPT: DEFAULT_PROMPT, + CONF_CHAT_MODEL: DEFAULT_CHAT_MODEL, + CONF_TEMPERATURE: DEFAULT_TEMPERATURE, + CONF_TOP_P: DEFAULT_TOP_P, + CONF_TOP_K: DEFAULT_TOP_K, + } +) + + +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: + """Validate the user input allows us to connect. + + Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. + """ + palm.configure(api_key=data[CONF_API_KEY]) + await hass.async_add_executor_job(partial(palm.list_models)) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Google Generative AI Conversation.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA + ) + + errors = {} + + try: + await validate_input(self.hass, user_input) + except ClientError as err: + if err.reason == "API_KEY_INVALID": + errors["base"] = "invalid_auth" + else: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title="Google Generative AI Conversation", data=user_input + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + @staticmethod + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> config_entries.OptionsFlow: + """Create the options flow.""" + return OptionsFlow(config_entry) + + +class OptionsFlow(config_entries.OptionsFlow): + """Google Generative AI config flow options handler.""" + + def __init__(self, config_entry: config_entries.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 the options.""" + if user_input is not None: + return self.async_create_entry( + title="Google Generative AI Conversation", data=user_input + ) + schema = google_generative_ai_config_option_schema(self.config_entry.options) + return self.async_show_form( + step_id="init", + data_schema=vol.Schema(schema), + ) + + +def google_generative_ai_config_option_schema( + options: MappingProxyType[str, Any] +) -> dict: + """Return a schema for Google Generative AI completion options.""" + if not options: + options = DEFAULT_OPTIONS + return { + vol.Optional( + CONF_PROMPT, + description={"suggested_value": options[CONF_PROMPT]}, + default=DEFAULT_PROMPT, + ): TemplateSelector(), + vol.Optional( + CONF_CHAT_MODEL, + description={ + "suggested_value": options.get(CONF_CHAT_MODEL, DEFAULT_CHAT_MODEL) + }, + default=DEFAULT_CHAT_MODEL, + ): str, + vol.Optional( + CONF_TEMPERATURE, + description={"suggested_value": options[CONF_TEMPERATURE]}, + default=DEFAULT_TEMPERATURE, + ): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)), + vol.Optional( + CONF_TOP_P, + description={"suggested_value": options[CONF_TOP_P]}, + default=DEFAULT_TOP_P, + ): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)), + vol.Optional( + CONF_TOP_K, + description={"suggested_value": options[CONF_TOP_K]}, + default=DEFAULT_TOP_K, + ): int, + } diff --git a/homeassistant/components/google_generative_ai_conversation/const.py b/homeassistant/components/google_generative_ai_conversation/const.py new file mode 100644 index 000000000000..9664552e4362 --- /dev/null +++ b/homeassistant/components/google_generative_ai_conversation/const.py @@ -0,0 +1,33 @@ +"""Constants for the Google Generative AI Conversation integration.""" + +DOMAIN = "google_generative_ai_conversation" +CONF_PROMPT = "prompt" +DEFAULT_PROMPT = """This smart home is controlled by Home Assistant. + +An overview of the areas and the devices in this smart home: +{%- for area in areas() %} + {%- set area_info = namespace(printed=false) %} + {%- for device in area_devices(area) -%} + {%- if not device_attr(device, "disabled_by") and not device_attr(device, "entry_type") and device_attr(device, "name") %} + {%- if not area_info.printed %} + +{{ area_name(area) }}: + {%- set area_info.printed = true %} + {%- endif %} +- {{ device_attr(device, "name") }}{% if device_attr(device, "model") and (device_attr(device, "model") | string) not in (device_attr(device, "name") | string) %} ({{ device_attr(device, "model") }}){% endif %} + {%- endif %} + {%- endfor %} +{%- endfor %} + +Answer the user's questions about the world truthfully. + +If the user wants to control a device, reject the request and suggest using the Home Assistant app. +""" +CONF_CHAT_MODEL = "chat_model" +DEFAULT_CHAT_MODEL = "models/chat-bison-001" +CONF_TEMPERATURE = "temperature" +DEFAULT_TEMPERATURE = 0.25 +CONF_TOP_P = "top_p" +DEFAULT_TOP_P = 0.95 +CONF_TOP_K = "top_k" +DEFAULT_TOP_K = 40 diff --git a/homeassistant/components/google_generative_ai_conversation/manifest.json b/homeassistant/components/google_generative_ai_conversation/manifest.json new file mode 100644 index 000000000000..52de92155354 --- /dev/null +++ b/homeassistant/components/google_generative_ai_conversation/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "google_generative_ai_conversation", + "name": "Google Generative AI Conversation", + "codeowners": ["@tronikos"], + "config_flow": true, + "dependencies": ["conversation"], + "documentation": "https://www.home-assistant.io/integrations/google_generative_ai_conversation", + "integration_type": "service", + "iot_class": "cloud_polling", + "requirements": ["google-generativeai==0.1.0rc2"] +} diff --git a/homeassistant/components/google_generative_ai_conversation/strings.json b/homeassistant/components/google_generative_ai_conversation/strings.json new file mode 100644 index 000000000000..2df5398222c8 --- /dev/null +++ b/homeassistant/components/google_generative_ai_conversation/strings.json @@ -0,0 +1,29 @@ +{ + "config": { + "step": { + "user": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + }, + "options": { + "step": { + "init": { + "data": { + "prompt": "Prompt Template", + "model": "Model", + "temperature": "Temperature", + "top_p": "Top P", + "top_k": "Top K" + } + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 82401badcd2f..48c4051bb841 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -166,6 +166,7 @@ FLOWS = { "goodwe", "google", "google_assistant_sdk", + "google_generative_ai_conversation", "google_mail", "google_sheets", "google_travel_time", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 490f3cf9a077..7be4a9d5a1ee 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2029,6 +2029,12 @@ "iot_class": "cloud_polling", "name": "Google Domains" }, + "google_generative_ai_conversation": { + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling", + "name": "Google Generative AI Conversation" + }, "google_mail": { "integration_type": "service", "config_flow": true, diff --git a/requirements_all.txt b/requirements_all.txt index ce8f8ae767a4..df86675a7c92 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -816,6 +816,9 @@ google-cloud-pubsub==2.13.11 # homeassistant.components.google_cloud google-cloud-texttospeech==2.12.3 +# homeassistant.components.google_generative_ai_conversation +google-generativeai==0.1.0rc2 + # homeassistant.components.nest google-nest-sdm==2.2.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fe1e3107c124..1239b3010c9a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -635,6 +635,9 @@ google-api-python-client==2.71.0 # homeassistant.components.google_pubsub google-cloud-pubsub==2.13.11 +# homeassistant.components.google_generative_ai_conversation +google-generativeai==0.1.0rc2 + # homeassistant.components.nest google-nest-sdm==2.2.4 diff --git a/tests/components/google_generative_ai_conversation/__init__.py b/tests/components/google_generative_ai_conversation/__init__.py new file mode 100644 index 000000000000..8f789d9737e3 --- /dev/null +++ b/tests/components/google_generative_ai_conversation/__init__.py @@ -0,0 +1 @@ +"""Tests for the Google Generative AI Conversation integration.""" diff --git a/tests/components/google_generative_ai_conversation/conftest.py b/tests/components/google_generative_ai_conversation/conftest.py new file mode 100644 index 000000000000..0a45a991bf8c --- /dev/null +++ b/tests/components/google_generative_ai_conversation/conftest.py @@ -0,0 +1,31 @@ +"""Tests helpers.""" +from unittest.mock import patch + +import pytest + +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry(hass): + """Mock a config entry.""" + entry = MockConfigEntry( + domain="google_generative_ai_conversation", + data={ + "api_key": "bla", + }, + ) + entry.add_to_hass(hass) + return entry + + +@pytest.fixture +async def mock_init_component(hass, mock_config_entry): + """Initialize integration.""" + with patch("google.generativeai.get_model"): + assert await async_setup_component( + hass, "google_generative_ai_conversation", {} + ) + await hass.async_block_till_done() diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr new file mode 100644 index 000000000000..636a46e42f56 --- /dev/null +++ b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr @@ -0,0 +1,33 @@ +# serializer version: 1 +# name: test_default_prompt + dict({ + 'context': ''' + This smart home is controlled by Home Assistant. + + An overview of the areas and the devices in this smart home: + + Test Area: + - Test Device (Test Model) + + Test Area 2: + - Test Device 2 + - Test Device 3 (Test Model 3A) + - Test Device 4 + - 1 (3) + + Answer the user's questions about the world truthfully. + + If the user wants to control a device, reject the request and suggest using the Home Assistant app. + ''', + 'messages': list([ + dict({ + 'author': '0', + 'content': 'hello', + }), + ]), + 'model': 'models/chat-bison-001', + 'temperature': 0.25, + 'top_k': 40, + 'top_p': 0.95, + }) +# --- diff --git a/tests/components/google_generative_ai_conversation/test_config_flow.py b/tests/components/google_generative_ai_conversation/test_config_flow.py new file mode 100644 index 000000000000..0b7072f4ef08 --- /dev/null +++ b/tests/components/google_generative_ai_conversation/test_config_flow.py @@ -0,0 +1,118 @@ +"""Test the Google Generative AI Conversation config flow.""" +from unittest.mock import patch + +from google.api_core.exceptions import ClientError +from google.rpc.error_details_pb2 import ErrorInfo +import pytest + +from homeassistant import config_entries +from homeassistant.components.google_generative_ai_conversation.const import ( + CONF_CHAT_MODEL, + CONF_TOP_K, + CONF_TOP_P, + DEFAULT_CHAT_MODEL, + DEFAULT_TOP_K, + DEFAULT_TOP_P, + DOMAIN, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_form(hass: HomeAssistant) -> None: + """Test we get the form.""" + # Pretend we already set up a config entry. + hass.config.components.add("google_generative_ai_conversation") + MockConfigEntry( + domain=DOMAIN, + state=config_entries.ConfigEntryState.LOADED, + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] is None + + with patch( + "homeassistant.components.google_generative_ai_conversation.config_flow.palm.list_models", + ), patch( + "homeassistant.components.google_generative_ai_conversation.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "api_key": "bla", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["data"] == { + "api_key": "bla", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_options( + hass: HomeAssistant, mock_config_entry, mock_init_component +) -> None: + """Test the options form.""" + options_flow = await hass.config_entries.options.async_init( + mock_config_entry.entry_id + ) + options = await hass.config_entries.options.async_configure( + options_flow["flow_id"], + { + "prompt": "Speak like a pirate", + "temperature": 0.3, + }, + ) + await hass.async_block_till_done() + assert options["type"] == FlowResultType.CREATE_ENTRY + assert options["data"]["prompt"] == "Speak like a pirate" + assert options["data"]["temperature"] == 0.3 + assert options["data"][CONF_CHAT_MODEL] == DEFAULT_CHAT_MODEL + assert options["data"][CONF_TOP_P] == DEFAULT_TOP_P + assert options["data"][CONF_TOP_K] == DEFAULT_TOP_K + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + ( + ClientError(message="some error"), + "cannot_connect", + ), + ( + ClientError( + message="invalid api key", + error_info=ErrorInfo(reason="API_KEY_INVALID"), + ), + "invalid_auth", + ), + (Exception, "unknown"), + ], +) +async def test_form_errors(hass: HomeAssistant, side_effect, error) -> None: + """Test we handle errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.google_generative_ai_conversation.config_flow.palm.list_models", + side_effect=side_effect, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "api_key": "bla", + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": error} diff --git a/tests/components/google_generative_ai_conversation/test_init.py b/tests/components/google_generative_ai_conversation/test_init.py new file mode 100644 index 000000000000..7335903b43b4 --- /dev/null +++ b/tests/components/google_generative_ai_conversation/test_init.py @@ -0,0 +1,137 @@ +"""Tests for the Google Generative AI Conversation integration.""" +from unittest.mock import patch + +from google.api_core.exceptions import ClientError +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components import conversation +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import area_registry as ar, device_registry as dr, intent + +from tests.common import MockConfigEntry + + +async def test_default_prompt( + hass: HomeAssistant, + mock_init_component, + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test that the default prompt works.""" + for i in range(3): + area_registry.async_create(f"{i}Empty Area") + + device_registry.async_get_or_create( + config_entry_id="1234", + connections={("test", "1234")}, + name="Test Device", + manufacturer="Test Manufacturer", + model="Test Model", + suggested_area="Test Area", + ) + for i in range(3): + device_registry.async_get_or_create( + config_entry_id="1234", + connections={("test", f"{i}abcd")}, + name="Test Service", + manufacturer="Test Manufacturer", + model="Test Model", + suggested_area="Test Area", + entry_type=dr.DeviceEntryType.SERVICE, + ) + device_registry.async_get_or_create( + config_entry_id="1234", + connections={("test", "5678")}, + name="Test Device 2", + manufacturer="Test Manufacturer 2", + model="Device 2", + suggested_area="Test Area 2", + ) + device_registry.async_get_or_create( + config_entry_id="1234", + connections={("test", "9876")}, + name="Test Device 3", + manufacturer="Test Manufacturer 3", + model="Test Model 3A", + suggested_area="Test Area 2", + ) + device_registry.async_get_or_create( + config_entry_id="1234", + connections={("test", "qwer")}, + name="Test Device 4", + suggested_area="Test Area 2", + ) + device = device_registry.async_get_or_create( + config_entry_id="1234", + connections={("test", "9876-disabled")}, + name="Test Device 3", + manufacturer="Test Manufacturer 3", + model="Test Model 3A", + suggested_area="Test Area 2", + ) + device_registry.async_update_device( + device.id, disabled_by=dr.DeviceEntryDisabler.USER + ) + device_registry.async_get_or_create( + config_entry_id="1234", + connections={("test", "9876-no-name")}, + manufacturer="Test Manufacturer NoName", + model="Test Model NoName", + suggested_area="Test Area 2", + ) + device_registry.async_get_or_create( + config_entry_id="1234", + connections={("test", "9876-integer-values")}, + name=1, + manufacturer=2, + model=3, + suggested_area="Test Area 2", + ) + with patch("google.generativeai.chat_async") as mock_chat: + result = await conversation.async_converse(hass, "hello", None, Context()) + + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert mock_chat.mock_calls[0][2] == snapshot + + +async def test_error_handling(hass: HomeAssistant, mock_init_component) -> None: + """Test that the default prompt works.""" + with patch("google.generativeai.chat_async", side_effect=ClientError("")): + result = await conversation.async_converse(hass, "hello", None, Context()) + + assert result.response.response_type == intent.IntentResponseType.ERROR, result + assert result.response.error_code == "unknown", result + + +async def test_template_error( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test that template error handling works.""" + hass.config_entries.async_update_entry( + mock_config_entry, + options={ + "prompt": "talk like a {% if True %}smarthome{% else %}pirate please.", + }, + ) + with patch( + "google.generativeai.get_model", + ), patch("google.generativeai.chat_async"): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + result = await conversation.async_converse(hass, "hello", None, Context()) + + assert result.response.response_type == intent.IntentResponseType.ERROR, result + assert result.response.error_code == "unknown", result + + +async def test_conversation_agent( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, +) -> None: + """Test GoogleGenerativeAIAgent.""" + agent = await conversation._get_agent_manager(hass).async_get_agent( + mock_config_entry.entry_id + ) + assert agent.supported_languages == "*" From b993fe1c9d6aafc9fbf6b1cb8785098e8a22cc56 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 16 May 2023 21:42:37 -0500 Subject: [PATCH 379/822] Cache generation of the service descriptions (#93131) --- .../components/websocket_api/commands.py | 52 +++++++++---------- .../components/websocket_api/http.py | 3 +- .../components/websocket_api/messages.py | 23 ++++++-- homeassistant/helpers/service.py | 23 ++++++-- .../components/websocket_api/test_commands.py | 13 ++--- tests/helpers/test_service.py | 17 ++++++ 6 files changed, 86 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index dd3f6cdbef06..b5dabf8b7339 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -33,6 +33,7 @@ from homeassistant.helpers.json import ( JSON_DUMP, ExtendedJSONEncoder, find_paths_unserializable_data, + json_dumps, ) from homeassistant.helpers.service import async_get_all_descriptions from homeassistant.loader import ( @@ -48,17 +49,9 @@ 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 -_STATES_TEMPLATE = "__STATES__" -_STATES_JSON_TEMPLATE = '"__STATES__"' -_HANDLE_SUBSCRIBE_ENTITIES_TEMPLATE = JSON_DUMP( - messages.event_message( - messages.IDEN_TEMPLATE, {messages.ENTITY_EVENT_ADD: _STATES_TEMPLATE} - ) -) -_HANDLE_GET_STATES_TEMPLATE = JSON_DUMP( - messages.result_message(messages.IDEN_TEMPLATE, _STATES_TEMPLATE) -) +ALL_SERVICE_DESCRIPTIONS_JSON_CACHE = "websocket_api_all_service_descriptions_json" @callback @@ -280,15 +273,8 @@ def _send_handle_get_states_response( connection: ActiveConnection, msg_id: int, serialized_states: list[str] ) -> None: """Send handle get states response.""" - connection.send_message( - _HANDLE_GET_STATES_TEMPLATE.replace( - messages.IDEN_JSON_TEMPLATE, str(msg_id), 1 - ).replace( - _STATES_JSON_TEMPLATE, - "[" + ",".join(serialized_states) + "]", - 1, - ) - ) + joined_states = ",".join(serialized_states) + connection.send_message(construct_result_message(msg_id, f"[{joined_states}]")) @callback @@ -359,25 +345,35 @@ def _send_handle_entities_init_response( connection: ActiveConnection, msg_id: int, serialized_states: list[str] ) -> None: """Send handle entities init response.""" + joined_states = ",".join(serialized_states) connection.send_message( - _HANDLE_SUBSCRIBE_ENTITIES_TEMPLATE.replace( - messages.IDEN_JSON_TEMPLATE, str(msg_id), 1 - ).replace( - _STATES_JSON_TEMPLATE, - "{" + ",".join(serialized_states) + "}", - 1, - ) + construct_event_message(msg_id, f'{{"a":{{{joined_states}}}}}') ) +async def _async_get_all_descriptions_json(hass: HomeAssistant) -> str: + """Return JSON of descriptions (i.e. user documentation) for all service calls.""" + descriptions = await async_get_all_descriptions(hass) + if ALL_SERVICE_DESCRIPTIONS_JSON_CACHE in hass.data: + cached_descriptions, cached_json_payload = hass.data[ + ALL_SERVICE_DESCRIPTIONS_JSON_CACHE + ] + # If the descriptions are the same, return the cached JSON payload + if cached_descriptions is descriptions: + return cast(str, cached_json_payload) + json_payload = json_dumps(descriptions) + hass.data[ALL_SERVICE_DESCRIPTIONS_JSON_CACHE] = (descriptions, json_payload) + return json_payload + + @decorators.websocket_command({vol.Required("type"): "get_services"}) @decorators.async_response async def handle_get_services( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle get services command.""" - descriptions = await async_get_all_descriptions(hass) - connection.send_result(msg["id"], descriptions) + payload = await _async_get_all_descriptions_json(hass) + connection.send_message(construct_result_message(msg["id"], payload)) @callback diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py index 310247709f82..5ca5ea625789 100644 --- a/homeassistant/components/websocket_api/http.py +++ b/homeassistant/components/websocket_api/http.py @@ -135,7 +135,8 @@ class WebSocketHandler: ) messages_remaining -= 1 - coalesced_messages = "[" + ",".join(messages) + "]" + joined_messages = ",".join(messages) + coalesced_messages = f"[{joined_messages}]" debug("Sending %s", coalesced_messages) await send_str(coalesced_messages) finally: diff --git a/homeassistant/components/websocket_api/messages.py b/homeassistant/components/websocket_api/messages.py index 4fc604df2dcd..3d85f984e9af 100644 --- a/homeassistant/components/websocket_api/messages.py +++ b/homeassistant/components/websocket_api/messages.py @@ -18,7 +18,6 @@ from homeassistant.core import Event, State from homeassistant.helpers import config_validation as cv from homeassistant.helpers.json import JSON_DUMP, find_paths_unserializable_data from homeassistant.util.json import format_unserializable_data -from homeassistant.util.yaml.loader import JSON_TYPE from . import const @@ -44,11 +43,17 @@ ENTITY_EVENT_REMOVE = "r" ENTITY_EVENT_CHANGE = "c" -def result_message(iden: JSON_TYPE | int, result: Any = None) -> dict[str, Any]: +def result_message(iden: int, result: Any = None) -> dict[str, Any]: """Return a success result message.""" return {"id": iden, "type": const.TYPE_RESULT, "success": True, "result": result} +def construct_result_message(iden: int, payload: str) -> str: + """Construct a success result message JSON.""" + iden_str = str(iden) + return f'{{"id":{iden_str},"type":"result","success":true,"result":{payload}}}' + + def error_message(iden: int | None, code: str, message: str) -> dict[str, Any]: """Return an error result message.""" return { @@ -59,7 +64,13 @@ def error_message(iden: int | None, code: str, message: str) -> dict[str, Any]: } -def event_message(iden: JSON_TYPE | int, event: Any) -> dict[str, Any]: +def construct_event_message(iden: int, payload: str) -> str: + """Construct an event message JSON.""" + iden_str = str(iden) + return f'{{"id":{iden_str},"type":"event","event":{payload}}}' + + +def event_message(iden: int, event: Any) -> dict[str, Any]: """Return an event message.""" return {"id": iden, "type": "event", "event": event} @@ -83,7 +94,7 @@ 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(event_message(IDEN_TEMPLATE, event)) + return message_to_json({"id": IDEN_TEMPLATE, "type": "event", "event": event}) def cached_state_diff_message(iden: int, event: Event) -> str: @@ -105,7 +116,9 @@ def _cached_state_diff_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(event_message(IDEN_TEMPLATE, _state_diff_event(event))) + return message_to_json( + {"id": IDEN_TEMPLATE, "type": "event", "event": _state_diff_event(event)} + ) def _state_diff_event(event: Event) -> dict: diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 14cf6a85a244..a9d7b906e733 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -59,6 +59,7 @@ CONF_SERVICE_ENTITY_ID = "entity_id" _LOGGER = logging.getLogger(__name__) SERVICE_DESCRIPTION_CACHE = "service_description_cache" +ALL_SERVICE_DESCRIPTIONS_CACHE = "all_service_descriptions_cache" @cache @@ -559,17 +560,27 @@ async def async_get_all_descriptions( ) -> dict[str, dict[str, Any]]: """Return descriptions (i.e. user documentation) for all service calls.""" descriptions_cache = hass.data.setdefault(SERVICE_DESCRIPTION_CACHE, {}) - format_cache_key = "{}.{}".format services = hass.services.async_services() # See if there are new services not seen before. # Any service that we saw before already has an entry in description_cache. missing = set() + all_services = [] for domain in services: for service in services[domain]: - if format_cache_key(domain, service) not in descriptions_cache: + cache_key = (domain, service) + all_services.append(cache_key) + if cache_key not in descriptions_cache: missing.add(domain) - break + + # If we have a complete cache, check if it is still valid + if ALL_SERVICE_DESCRIPTIONS_CACHE in hass.data: + previous_all_services, previous_descriptions_cache = hass.data[ + ALL_SERVICE_DESCRIPTIONS_CACHE + ] + # If the services are the same, we can return the cache + if previous_all_services == all_services: + return cast(dict[str, dict[str, Any]], previous_descriptions_cache) # Files we loaded for missing descriptions loaded = {} @@ -595,7 +606,7 @@ async def async_get_all_descriptions( descriptions[domain] = {} for service in services[domain]: - cache_key = format_cache_key(domain, service) + cache_key = (domain, service) description = descriptions_cache.get(cache_key) # Cache missing descriptions @@ -622,6 +633,7 @@ async def async_get_all_descriptions( descriptions[domain][service] = description + hass.data[ALL_SERVICE_DESCRIPTIONS_CACHE] = (all_services, descriptions) return descriptions @@ -652,7 +664,8 @@ def async_set_service_schema( if "target" in schema: description["target"] = schema["target"] - hass.data[SERVICE_DESCRIPTION_CACHE][f"{domain}.{service}"] = description + hass.data.pop(ALL_SERVICE_DESCRIPTIONS_CACHE, None) + hass.data[SERVICE_DESCRIPTION_CACHE][(domain, service)] = description @bind_hass diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index dd1e583d2daa..9c2a05e96ca8 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -514,13 +514,14 @@ async def test_get_states(hass: HomeAssistant, websocket_client) -> None: async def test_get_services(hass: HomeAssistant, websocket_client) -> None: """Test get_services command.""" - await websocket_client.send_json({"id": 5, "type": "get_services"}) + for id_ in (5, 6): + await websocket_client.send_json({"id": id_, "type": "get_services"}) - msg = await websocket_client.receive_json() - assert msg["id"] == 5 - assert msg["type"] == const.TYPE_RESULT - assert msg["success"] - assert msg["result"] == hass.services.async_services() + msg = await websocket_client.receive_json() + assert msg["id"] == id_ + assert msg["type"] == const.TYPE_RESULT + assert msg["success"] + assert msg["result"] == hass.services.async_services() async def test_get_config(hass: HomeAssistant, websocket_client) -> None: diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index ff86f9c7e766..8e8123ac7af7 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -564,6 +564,23 @@ async def test_async_get_all_descriptions(hass: HomeAssistant) -> None: assert "description" in descriptions[logger.DOMAIN]["set_level"] assert "fields" in descriptions[logger.DOMAIN]["set_level"] + hass.services.async_register(logger.DOMAIN, "new_service", lambda x: None, None) + service.async_set_service_schema( + hass, logger.DOMAIN, "new_service", {"description": "new service"} + ) + descriptions = await service.async_get_all_descriptions(hass) + assert "description" in descriptions[logger.DOMAIN]["new_service"] + assert descriptions[logger.DOMAIN]["new_service"]["description"] == "new service" + + hass.services.async_register( + logger.DOMAIN, "another_new_service", lambda x: None, None + ) + descriptions = await service.async_get_all_descriptions(hass) + assert "another_new_service" in descriptions[logger.DOMAIN] + + # Verify the cache returns the same object + assert await service.async_get_all_descriptions(hass) is descriptions + async def test_call_with_required_features(hass: HomeAssistant, mock_entities) -> None: """Test service calls invoked only if entity has required features.""" From c522ea855d17f5072a6113852ce398a22800d333 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Wed, 17 May 2023 09:58:00 +0200 Subject: [PATCH 380/822] KNX Group Monitor: Load latest 50 Telegrams on start (#93153) * remove impossible test IndividualAddress telegrams are not processed by xknx.telegram_queue * Use Telegrams helper class for group monitor messages * Store 50 telegrams in deque for group monitor * Send recent telegrams at once on connection of group monitor * Update KNX-frontend to support group monitor prepopulation --- homeassistant/components/knx/manifest.json | 2 +- homeassistant/components/knx/telegrams.py | 15 +- homeassistant/components/knx/websocket.py | 160 +++++++++------------ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/knx/conftest.py | 21 +-- tests/components/knx/test_websocket.py | 54 ++++++- 7 files changed, 132 insertions(+), 124 deletions(-) diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index ad61b812386e..b850954e8a8d 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -13,6 +13,6 @@ "requirements": [ "xknx==2.9.0", "xknxproject==3.1.0", - "knx_frontend==2023.5.2.143855" + "knx_frontend==2023.5.16.204359" ] } diff --git a/homeassistant/components/knx/telegrams.py b/homeassistant/components/knx/telegrams.py index 815a0c00a931..4c5ac44f6b19 100644 --- a/homeassistant/components/knx/telegrams.py +++ b/homeassistant/components/knx/telegrams.py @@ -1,7 +1,9 @@ """KNX Telegram handler.""" from __future__ import annotations +from collections import deque from collections.abc import Callable +import datetime as dt from typing import TypedDict from xknx import XKNX @@ -10,6 +12,7 @@ from xknx.telegram import Telegram from xknx.telegram.apci import GroupValueResponse, GroupValueWrite from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback +import homeassistant.util.dt as dt_util from .project import KNXProject @@ -24,6 +27,8 @@ class TelegramDict(TypedDict): source: str source_name: str telegramtype: str + timestamp: dt.datetime + unit: str | None value: str | int | float | bool | None @@ -41,10 +46,12 @@ class Telegrams: match_for_outgoing=True, ) ) + self.recent_telegrams: deque[TelegramDict] = deque(maxlen=50) async def _xknx_telegram_cb(self, telegram: Telegram) -> None: """Handle incoming and outgoing telegrams from xknx.""" telegram_dict = self.telegram_to_dict(telegram) + self.recent_telegrams.appendleft(telegram_dict) for job in self._jobs: self.hass.async_run_hass_job(job, telegram_dict) @@ -70,6 +77,7 @@ class Telegrams: payload_data: int | tuple[int, ...] | None = None src_name = "" transcoder = None + unit = None value: str | int | float | bool | None = None if ( @@ -83,15 +91,16 @@ class Telegrams: if ( device := self.project.devices.get(f"{telegram.source_address}") ) is not None: - src_name = device["name"] + src_name = f"{device['manufacturer_name']} {device['name']}" if isinstance(telegram.payload, (GroupValueWrite, GroupValueResponse)): payload_data = telegram.payload.value.value if transcoder is not None: try: value = transcoder.from_knx(telegram.payload.value) + unit = transcoder.unit except XKNXException: - value = None + value = "Error decoding value" return TelegramDict( destination=f"{telegram.destination_address}", @@ -101,5 +110,7 @@ class Telegrams: source=f"{telegram.source_address}", source_name=src_name, telegramtype=telegram.payload.__class__.__name__, + timestamp=dt_util.as_local(dt_util.utcnow()), + unit=unit, value=value, ) diff --git a/homeassistant/components/knx/websocket.py b/homeassistant/components/knx/websocket.py index d5a41dce1461..d63ba89fbcc7 100644 --- a/homeassistant/components/knx/websocket.py +++ b/homeassistant/components/knx/websocket.py @@ -1,28 +1,22 @@ """KNX Websocket API.""" from __future__ import annotations -from collections.abc import Callable -from typing import Final +from typing import TYPE_CHECKING, Final from knx_frontend import get_build_id, locate_dir import voluptuous as vol -from xknx.dpt import DPTArray -from xknx.exceptions import XKNXException -from xknx.telegram import Telegram, TelegramDirection -from xknx.telegram.apci import GroupValueRead, GroupValueResponse, GroupValueWrite +from xknx.telegram import TelegramDirection from xknxproject.exceptions import XknxProjectException from homeassistant.components import panel_custom, websocket_api from homeassistant.core import HomeAssistant, callback -import homeassistant.util.dt as dt_util -from .const import ( - DOMAIN, - AsyncMessageCallbackType, - KNXBusMonitorMessage, - MessageCallbackType, -) -from .project import KNXProject +from .const import DOMAIN, KNXBusMonitorMessage +from .telegrams import TelegramDict + +if TYPE_CHECKING: + from . import KNXModule + URL_BASE: Final = "/knx_static" @@ -65,10 +59,10 @@ def ws_info( msg: dict, ) -> None: """Handle get info command.""" - xknx = hass.data[DOMAIN].xknx + knx: KNXModule = hass.data[DOMAIN] _project_info = None - if project_info := hass.data[DOMAIN].project.info: + if project_info := knx.project.info: _project_info = { "name": project_info["name"], "last_modified": project_info["last_modified"], @@ -78,9 +72,9 @@ def ws_info( connection.send_result( msg["id"], { - "version": xknx.version, - "connected": xknx.connection_manager.connected.is_set(), - "current_address": str(xknx.current_address), + "version": knx.xknx.version, + "connected": knx.xknx.connection_manager.connected.is_set(), + "current_address": str(knx.xknx.current_address), "project": _project_info, }, ) @@ -101,9 +95,9 @@ async def ws_project_file_process( msg: dict, ) -> None: """Handle get info command.""" - knx_project = hass.data[DOMAIN].project + knx: KNXModule = hass.data[DOMAIN] try: - await knx_project.process_project_file( + await knx.project.process_project_file( file_id=msg["file_id"], password=msg["password"], ) @@ -130,8 +124,8 @@ async def ws_project_file_remove( msg: dict, ) -> None: """Handle get info command.""" - knx_project = hass.data[DOMAIN].project - await knx_project.remove_project_file() + knx: KNXModule = hass.data[DOMAIN] + await knx.project.remove_project_file() connection.send_result(msg["id"]) @@ -147,10 +141,17 @@ def ws_group_monitor_info( msg: dict, ) -> None: """Handle get info command of group monitor.""" - project_loaded = hass.data[DOMAIN].project.loaded + knx: KNXModule = hass.data[DOMAIN] + recent_telegrams = [ + _telegram_dict_to_group_monitor(telegram) + for telegram in knx.telegrams.recent_telegrams + ] connection.send_result( msg["id"], - {"project_loaded": bool(project_loaded)}, + { + "project_loaded": knx.project.loaded, + "recent_telegrams": recent_telegrams, + }, ) @@ -166,86 +167,53 @@ def ws_subscribe_telegram( msg: dict, ) -> None: """Subscribe to incoming and outgoing KNX telegrams.""" - project: KNXProject = hass.data[DOMAIN].project - - async def forward_telegrams(telegram: Telegram) -> None: - """Forward events to websocket.""" - payload: str - dpt_payload = None - if isinstance(telegram.payload, (GroupValueWrite, GroupValueResponse)): - dpt_payload = telegram.payload.value - if isinstance(dpt_payload, DPTArray): - payload = f"0x{bytes(dpt_payload.value).hex()}" - else: - payload = f"{dpt_payload.value:d}" - elif isinstance(telegram.payload, GroupValueRead): - payload = "" - else: - return - - direction = ( - "group_monitor_incoming" - if telegram.direction is TelegramDirection.INCOMING - else "group_monitor_outgoing" - ) - dst = str(telegram.destination_address) - src = str(telegram.source_address) - bus_message: KNXBusMonitorMessage = KNXBusMonitorMessage( - destination_address=dst, - destination_text=None, - payload=payload, - type=str(telegram.payload.__class__.__name__), - value=None, - source_address=src, - source_text=None, - direction=direction, - timestamp=dt_util.as_local(dt_util.utcnow()).strftime("%H:%M:%S.%f")[:-3], - ) - if project.loaded: - if ga_infos := project.group_addresses.get(dst): - bus_message["destination_text"] = ga_infos.name - if dpt_payload is not None and ga_infos.transcoder is not None: - try: - value = ga_infos.transcoder.from_knx(dpt_payload) - except XKNXException: - bus_message["value"] = "Error decoding value" - else: - unit = ( - f" {ga_infos.transcoder.unit}" - if ga_infos.transcoder.unit is not None - else "" - ) - bus_message["value"] = f"{value}{unit}" - if ia_infos := project.devices.get(src): - bus_message[ - "source_text" - ] = f"{ia_infos['manufacturer_name']} {ia_infos['name']}" + knx: KNXModule = hass.data[DOMAIN] + @callback + def forward_telegram(telegram: TelegramDict) -> None: + """Forward telegram to websocket subscription.""" connection.send_event( msg["id"], - bus_message, + _telegram_dict_to_group_monitor(telegram), ) - connection.subscriptions[msg["id"]] = async_subscribe_telegrams( - hass, forward_telegrams + connection.subscriptions[msg["id"]] = knx.telegrams.async_listen_telegram( + action=forward_telegram, + name="KNX GroupMonitor subscription", ) - connection.send_result(msg["id"]) -def async_subscribe_telegrams( - hass: HomeAssistant, - telegram_callback: AsyncMessageCallbackType | MessageCallbackType, -) -> Callable[[], None]: - """Subscribe to telegram received callback.""" - xknx = hass.data[DOMAIN].xknx - - unregister = xknx.telegram_queue.register_telegram_received_cb( - telegram_callback, match_for_outgoing=True +def _telegram_dict_to_group_monitor(telegram: TelegramDict) -> KNXBusMonitorMessage: + """Convert a TelegramDict to a KNXBusMonitorMessage object.""" + direction = ( + "group_monitor_incoming" + if telegram["direction"] == TelegramDirection.INCOMING.value + else "group_monitor_outgoing" ) - def async_remove() -> None: - """Remove callback.""" - xknx.telegram_queue.unregister_telegram_received_cb(unregister) + _payload = telegram["payload"] + if isinstance(_payload, tuple): + payload = f"0x{bytes(_payload).hex()}" + elif isinstance(_payload, int): + payload = f"{_payload:d}" + else: + payload = "" - return async_remove + timestamp = telegram["timestamp"].strftime("%H:%M:%S.%f")[:-3] + + if (value := telegram["value"]) is not None: + unit = telegram["unit"] + value = f"{value}{' ' + unit if unit else ''}" + + return KNXBusMonitorMessage( + destination_address=telegram["destination"], + destination_text=telegram["destination_name"], + direction=direction, + payload=payload, + source_address=telegram["source"], + source_text=telegram["source_name"], + timestamp=timestamp, + type=telegram["telegramtype"], + value=value, + ) diff --git a/requirements_all.txt b/requirements_all.txt index df86675a7c92..08334567f61c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1028,7 +1028,7 @@ kegtron-ble==0.4.0 kiwiki-client==0.1.1 # homeassistant.components.knx -knx_frontend==2023.5.2.143855 +knx_frontend==2023.5.16.204359 # homeassistant.components.konnected konnected==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1239b3010c9a..8a3369c39213 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -787,7 +787,7 @@ justnimbus==0.6.0 kegtron-ble==0.4.0 # homeassistant.components.knx -knx_frontend==2023.5.2.143855 +knx_frontend==2023.5.16.204359 # homeassistant.components.konnected konnected==1.2.0 diff --git a/tests/components/knx/conftest.py b/tests/components/knx/conftest.py index 79a3de917154..084a3a37c270 100644 --- a/tests/components/knx/conftest.py +++ b/tests/components/knx/conftest.py @@ -12,13 +12,7 @@ from xknx.dpt import DPTArray, DPTBinary from xknx.io import DEFAULT_MCAST_GRP, DEFAULT_MCAST_PORT from xknx.telegram import Telegram, TelegramDirection from xknx.telegram.address import GroupAddress, IndividualAddress -from xknx.telegram.apci import ( - APCI, - GroupValueRead, - GroupValueResponse, - GroupValueWrite, - IndividualAddressRead, -) +from xknx.telegram.apci import APCI, GroupValueRead, GroupValueResponse, GroupValueWrite from homeassistant.components.knx.const import ( CONF_KNX_AUTOMATIC, @@ -210,19 +204,6 @@ class KNXTestKit: await self.xknx.telegrams.join() await self.hass.async_block_till_done() - async def receive_individual_address_read(self, source: str | None = None): - """Inject incoming IndividualAddressRead telegram.""" - self.xknx.telegrams.put_nowait( - Telegram( - destination_address=IndividualAddress(self.INDIVIDUAL_ADDRESS), - direction=TelegramDirection.INCOMING, - payload=IndividualAddressRead(), - source_address=IndividualAddress(source or "1.3.5"), - ) - ) - await self.xknx.telegrams.join() - await self.hass.async_block_till_done() - async def receive_read(self, group_address: str, source: str | None = None) -> None: """Inject incoming GroupValueRead telegram.""" await self._receive_telegram( diff --git a/tests/components/knx/test_websocket.py b/tests/components/knx/test_websocket.py index c053e4fa9cbd..115b92f70e8d 100644 --- a/tests/components/knx/test_websocket.py +++ b/tests/components/knx/test_websocket.py @@ -150,6 +150,55 @@ async def test_knx_group_monitor_info_command( res = await client.receive_json() assert res["success"], res assert res["result"]["project_loaded"] is False + assert res["result"]["recent_telegrams"] == [] + + +async def test_knx_subscribe_telegrams_command_recent_telegrams( + hass: HomeAssistant, knx: KNXTestKit, hass_ws_client: WebSocketGenerator +): + """Test knx/subscribe_telegrams command sending recent telegrams.""" + await knx.setup_integration( + { + SwitchSchema.PLATFORM: { + CONF_NAME: "test", + KNX_ADDRESS: "1/2/4", + } + } + ) + + # send incoming telegram + await knx.receive_write("1/3/4", True) + # send outgoing telegram + await hass.services.async_call( + "switch", "turn_on", {"entity_id": "switch.test"}, blocking=True + ) + await knx.assert_write("1/2/4", 1) + + # connect websocket after telegrams have been sent + client = await hass_ws_client(hass) + await client.send_json({"id": 6, "type": "knx/group_monitor_info"}) + res = await client.receive_json() + assert res["success"], res + assert res["result"]["project_loaded"] is False + + recent_tgs = res["result"]["recent_telegrams"] + assert len(recent_tgs) == 2 + # telegrams are sorted from newest to oldest + assert recent_tgs[0]["destination_address"] == "1/2/4" + assert recent_tgs[0]["payload"] == "1" + assert recent_tgs[0]["type"] == "GroupValueWrite" + assert ( + recent_tgs[0]["source_address"] == "0.0.0" + ) # needs to be the IA currently connected to + assert recent_tgs[0]["direction"] == "group_monitor_outgoing" + assert recent_tgs[0]["timestamp"] is not None + + assert recent_tgs[1]["destination_address"] == "1/3/4" + assert recent_tgs[1]["payload"] == "1" + assert recent_tgs[1]["type"] == "GroupValueWrite" + assert recent_tgs[1]["source_address"] == "1.2.3" + assert recent_tgs[1]["direction"] == "group_monitor_incoming" + assert recent_tgs[1]["timestamp"] is not None async def test_knx_subscribe_telegrams_command_no_project( @@ -169,13 +218,12 @@ async def test_knx_subscribe_telegrams_command_no_project( res = await client.receive_json() assert res["success"], res - # send incoming events + # send incoming telegrams await knx.receive_read("1/2/3") await knx.receive_write("1/3/4", True) await knx.receive_write("1/3/4", False) - await knx.receive_individual_address_read() await knx.receive_write("1/3/8", (0x34, 0x45)) - # send outgoing events + # send outgoing telegrams await hass.services.async_call( "switch", "turn_on", {"entity_id": "switch.test"}, blocking=True ) From f564a0ca24a9a6f3aad3a9226a50d68cf10e63b7 Mon Sep 17 00:00:00 2001 From: Ziv <16467659+ziv1234@users.noreply.github.com> Date: Wed, 17 May 2023 14:05:36 +0300 Subject: [PATCH 381/822] Fix failure to register services in dynalite (#93069) * fixed bug that fails to register services when there is no YAML config * style improvement per thecode's suggestion --- homeassistant/components/dynalite/__init__.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/dynalite/__init__.py b/homeassistant/components/dynalite/__init__.py index c7f23af5fb1c..77880fd74cbe 100644 --- a/homeassistant/components/dynalite/__init__.py +++ b/homeassistant/components/dynalite/__init__.py @@ -43,19 +43,11 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Dynalite platform.""" - conf = config.get(DOMAIN) + conf = config.get(DOMAIN, {}) LOGGER.debug("Setting up dynalite component config = %s", conf) - - if conf is None: - conf = {} - hass.data[DOMAIN] = {} - # User has configured bridges - if CONF_BRIDGES not in conf: - return True - - bridges = conf[CONF_BRIDGES] + bridges = conf.get(CONF_BRIDGES, []) for bridge_conf in bridges: host = bridge_conf[CONF_HOST] From 32481065a832069b2543ae20474b23683ad04410 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 17 May 2023 06:06:52 -0500 Subject: [PATCH 382/822] Increase size of valid entity id cache (#93125) --- homeassistant/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index 2806907f19f1..8e88820bf708 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -174,7 +174,7 @@ def valid_domain(domain: str) -> bool: return VALID_DOMAIN.match(domain) is not None -@functools.lru_cache(64) +@functools.lru_cache(512) def valid_entity_id(entity_id: str) -> bool: """Test if an entity ID is a valid format. From fb7c579216bd11cb433a3ec858677c8048336130 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 17 May 2023 13:10:08 +0200 Subject: [PATCH 383/822] Add missing entity attribute translations to humidifier entity component (#93206) --- .../components/humidifier/strings.json | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/homeassistant/components/humidifier/strings.json b/homeassistant/components/humidifier/strings.json index 0fca7c0a0a20..afaa05df462c 100644 --- a/homeassistant/components/humidifier/strings.json +++ b/homeassistant/components/humidifier/strings.json @@ -26,6 +26,34 @@ "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]" + }, + "state_attributes": { + "available_modes": { + "name": "Available modes" + }, + "humidity": { + "name": "Target humidity" + }, + "max_humidity": { + "name": "Max target humidity" + }, + "min_humidity": { + "name": "Min target humidity" + }, + "mode": { + "name": "Mode", + "state": { + "normal": "Normal", + "eco": "Eco", + "away": "Away", + "boost": "Boost", + "comfort": "Comfort", + "home": "Home", + "sleep": "Sleep", + "auto": "Auto", + "baby": "Baby" + } + } } }, "dehumidifier": { From 00b600bfa8cd446404af583869a6b522814c038c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 17 May 2023 06:14:31 -0500 Subject: [PATCH 384/822] Fix ONVIF cameras that change the xaddr for the pull point service (#93104) --- homeassistant/components/onvif/event.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/onvif/event.py b/homeassistant/components/onvif/event.py index dbff9660b12d..bb42e63c52e7 100644 --- a/homeassistant/components/onvif/event.py +++ b/homeassistant/components/onvif/event.py @@ -7,7 +7,7 @@ import datetime as dt from aiohttp.web import Request from httpx import RemoteProtocolError, RequestError, TransportError -from onvif import ONVIFCamera, ONVIFService +from onvif import ONVIFCamera from onvif.client import ( NotificationManager, PullPointManager as ONVIFPullPointManager, @@ -240,7 +240,6 @@ class PullPointManager: self._hass = event_manager.hass self._name = event_manager.name - self._pullpoint_service: ONVIFService = None self._pullpoint_manager: ONVIFPullPointManager | None = None self._cancel_pull_messages: CALLBACK_TYPE | None = None @@ -317,7 +316,6 @@ class PullPointManager: self._pullpoint_manager = await self._device.create_pullpoint_manager( SUBSCRIPTION_TIME, self._event_manager.async_mark_events_stale ) - self._pullpoint_service = self._pullpoint_manager.get_service() await self._pullpoint_manager.set_synchronization_point() async def _async_unsubscribe_pullpoint(self) -> None: @@ -342,7 +340,7 @@ class PullPointManager: """Pull messages from device.""" if self._pullpoint_manager is None: return - assert self._pullpoint_service is not None, "PullPoint service does not exist" + service = self._pullpoint_manager.get_service() LOGGER.debug( "%s: Pulling PullPoint messages timeout=%s limit=%s", self._name, @@ -353,7 +351,7 @@ class PullPointManager: response = None try: if self._hass.is_running: - response = await self._pullpoint_service.PullMessages( + response = await service.PullMessages( { "MessageLimit": PULLPOINT_MESSAGE_LIMIT, "Timeout": PULLPOINT_POLL_TIME, @@ -445,7 +443,7 @@ class PullPointManager: self.async_cancel_pull_messages() if self.state != PullPointManagerState.STARTED: return - if self._pullpoint_service: + if self._pullpoint_manager: when = delay if delay is not None else PULLPOINT_COOLDOWN_TIME self._cancel_pull_messages = async_call_later( self._hass, when, self._pull_messages_job From 7cdd6b4cbc17489e7813d4947725c58f3c2a7e50 Mon Sep 17 00:00:00 2001 From: Nerdix <70015952+N3rdix@users.noreply.github.com> Date: Wed, 17 May 2023 13:15:28 +0200 Subject: [PATCH 385/822] Increase timeout to 30 seconds for homeassistant_alerts integration (#93089) --- homeassistant/components/homeassistant_alerts/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/homeassistant_alerts/__init__.py b/homeassistant/components/homeassistant_alerts/__init__.py index 8b04f845709c..234f5ae4fed9 100644 --- a/homeassistant/components/homeassistant_alerts/__init__.py +++ b/homeassistant/components/homeassistant_alerts/__init__.py @@ -48,7 +48,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: try: response = await async_get_clientsession(hass).get( f"https://alerts.home-assistant.io/alerts/{alert.alert_id}.json", - timeout=aiohttp.ClientTimeout(total=10), + timeout=aiohttp.ClientTimeout(total=30), ) except asyncio.TimeoutError: _LOGGER.warning("Error fetching %s: timeout", alert.filename) From af1bac874543eedc07023376022ac4464555c7da Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 17 May 2023 06:25:28 -0500 Subject: [PATCH 386/822] Disconnect yale access locks at the stop event (#93192) --- homeassistant/components/august/manifest.json | 2 +- .../components/yalexs_ble/__init__.py | 18 +++++++++++++++--- .../components/yalexs_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 19 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index 4e5f8354a4cb..9f766c91df92 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.3.3", "yalexs-ble==2.1.16"] + "requirements": ["yalexs==1.3.3", "yalexs-ble==2.1.17"] } diff --git a/homeassistant/components/yalexs_ble/__init__.py b/homeassistant/components/yalexs_ble/__init__.py index 4a937585732f..4e9b75137459 100644 --- a/homeassistant/components/yalexs_ble/__init__.py +++ b/homeassistant/components/yalexs_ble/__init__.py @@ -15,8 +15,8 @@ from yalexs_ble import ( from homeassistant.components import bluetooth from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ADDRESS, Platform -from homeassistant.core import HomeAssistant, callback +from homeassistant.const import CONF_ADDRESS, EVENT_HOMEASSISTANT_STOP, Platform +from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from .const import CONF_KEY, CONF_LOCAL_NAME, CONF_SLOT, DEVICE_TIMEOUT, DOMAIN @@ -45,7 +45,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Update from a ble callback.""" push_lock.update_advertisement(service_info.device, service_info.advertisement) - entry.async_on_unload(await push_lock.start()) + shutdown_callback: CALLBACK_TYPE | None = await push_lock.start() + + @callback + def _async_shutdown(event: Event | None = None) -> None: + nonlocal shutdown_callback + if shutdown_callback: + shutdown_callback() + shutdown_callback = None + + entry.async_on_unload(_async_shutdown) # We may already have the advertisement, so check for it. if service_info := async_find_existing_service_info(hass, local_name, address): @@ -97,6 +106,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload(push_lock.register_callback(_async_state_changed)) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_shutdown) + ) return True diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index 381229edeadc..8aa795b970e1 100644 --- a/homeassistant/components/yalexs_ble/manifest.json +++ b/homeassistant/components/yalexs_ble/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/yalexs_ble", "iot_class": "local_push", - "requirements": ["yalexs-ble==2.1.16"] + "requirements": ["yalexs-ble==2.1.17"] } diff --git a/requirements_all.txt b/requirements_all.txt index 08334567f61c..f7bc1a1ed6d7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2691,7 +2691,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.16 +yalexs-ble==2.1.17 # homeassistant.components.august yalexs==1.3.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8a3369c39213..6948bf7e56ae 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1958,7 +1958,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.16 +yalexs-ble==2.1.17 # homeassistant.components.august yalexs==1.3.3 From 08fd0fd441b9511aa55e41dba2bfbdc1b362ad7e Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 17 May 2023 13:33:52 +0200 Subject: [PATCH 387/822] Add virtual integration: Piper (#93182) --- homeassistant/components/piper/__init__.py | 1 + homeassistant/components/piper/manifest.json | 6 ++++++ homeassistant/generated/integrations.json | 5 +++++ 3 files changed, 12 insertions(+) create mode 100644 homeassistant/components/piper/__init__.py create mode 100644 homeassistant/components/piper/manifest.json diff --git a/homeassistant/components/piper/__init__.py b/homeassistant/components/piper/__init__.py new file mode 100644 index 000000000000..6a67fa3975f2 --- /dev/null +++ b/homeassistant/components/piper/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Piper.""" diff --git a/homeassistant/components/piper/manifest.json b/homeassistant/components/piper/manifest.json new file mode 100644 index 000000000000..de8f1dede516 --- /dev/null +++ b/homeassistant/components/piper/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "piper", + "name": "Piper", + "integration_type": "virtual", + "supported_by": "wyoming" +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 7be4a9d5a1ee..89026c1efe7e 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4156,6 +4156,11 @@ "config_flow": false, "iot_class": "local_polling" }, + "piper": { + "name": "Piper", + "integration_type": "virtual", + "supported_by": "wyoming" + }, "pjlink": { "name": "PJLink", "integration_type": "hub", From 1173039392d7e53e00774448939fbc096d5995c9 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 17 May 2023 13:34:09 +0200 Subject: [PATCH 388/822] Add virtual integration: Whisper (#93183) --- homeassistant/components/whisper/__init__.py | 1 + homeassistant/components/whisper/manifest.json | 6 ++++++ homeassistant/generated/integrations.json | 5 +++++ 3 files changed, 12 insertions(+) create mode 100644 homeassistant/components/whisper/__init__.py create mode 100644 homeassistant/components/whisper/manifest.json diff --git a/homeassistant/components/whisper/__init__.py b/homeassistant/components/whisper/__init__.py new file mode 100644 index 000000000000..572f91f68d37 --- /dev/null +++ b/homeassistant/components/whisper/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Whisper.""" diff --git a/homeassistant/components/whisper/manifest.json b/homeassistant/components/whisper/manifest.json new file mode 100644 index 000000000000..1bc0f12e07c3 --- /dev/null +++ b/homeassistant/components/whisper/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "whisper", + "name": "Whisper", + "integration_type": "virtual", + "supported_by": "wyoming" +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 89026c1efe7e..b3dc06926cad 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6197,6 +6197,11 @@ "config_flow": true, "iot_class": "cloud_push" }, + "whisper": { + "name": "Whisper", + "integration_type": "virtual", + "supported_by": "wyoming" + }, "whois": { "name": "Whois", "integration_type": "service", From 0828cc0e449f5ccbed7d1f5ad4411f20f751947a Mon Sep 17 00:00:00 2001 From: Michael Benz Date: Thu, 18 May 2023 05:16:54 +1000 Subject: [PATCH 389/822] Add light entity to Tuya Air Purifiers (#92861) --- homeassistant/components/tuya/light.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index 959a1834f8da..44b3494ca773 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -171,6 +171,15 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Air Purifier + # https://developer.tuya.com/en/docs/iot/f?id=K9gf46h2s6dzm + "kj": ( + TuyaLightEntityDescription( + key=DPCode.LIGHT, + name="Backlight", + entity_category=EntityCategory.CONFIG, + ), + ), # Air conditioner # https://developer.tuya.com/en/docs/iot/categorykt?id=Kaiuz0z71ov2n "kt": ( From b9798e4f4f01516dbec7b2d8f6070152accecf69 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Wed, 17 May 2023 13:56:49 -0600 Subject: [PATCH 390/822] Add support for SimpliSafe Smoke+CO detector (#93217) --- homeassistant/components/simplisafe/binary_sensor.py | 4 ++++ homeassistant/components/simplisafe/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/simplisafe/binary_sensor.py b/homeassistant/components/simplisafe/binary_sensor.py index 6b26eb7854be..d31dc5da2828 100644 --- a/homeassistant/components/simplisafe/binary_sensor.py +++ b/homeassistant/components/simplisafe/binary_sensor.py @@ -27,6 +27,7 @@ SUPPORTED_BATTERY_SENSOR_TYPES = [ DeviceTypes.MOTION, DeviceTypes.SIREN, DeviceTypes.SMOKE, + DeviceTypes.SMOKE_AND_CARBON_MONOXIDE, DeviceTypes.TEMPERATURE, ] @@ -38,6 +39,9 @@ TRIGGERED_SENSOR_TYPES = { DeviceTypes.MOTION: BinarySensorDeviceClass.MOTION, DeviceTypes.SIREN: BinarySensorDeviceClass.SAFETY, DeviceTypes.SMOKE: BinarySensorDeviceClass.SMOKE, + # Although this sensor can technically apply to both smoke and carbon, we use the + # SMOKE device class for simplicity: + DeviceTypes.SMOKE_AND_CARBON_MONOXIDE: BinarySensorDeviceClass.SMOKE, } diff --git a/homeassistant/components/simplisafe/manifest.json b/homeassistant/components/simplisafe/manifest.json index 184e1f306e0f..d137824b3dbf 100644 --- a/homeassistant/components/simplisafe/manifest.json +++ b/homeassistant/components/simplisafe/manifest.json @@ -13,5 +13,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["simplipy"], - "requirements": ["simplisafe-python==2023.04.0"] + "requirements": ["simplisafe-python==2023.05.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index f7bc1a1ed6d7..f4714dd15fca 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2357,7 +2357,7 @@ simplehound==0.3 simplepush==2.1.1 # homeassistant.components.simplisafe -simplisafe-python==2023.04.0 +simplisafe-python==2023.05.0 # homeassistant.components.sisyphus sisyphus-control==3.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6948bf7e56ae..1372b8351492 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1702,7 +1702,7 @@ simplehound==0.3 simplepush==2.1.1 # homeassistant.components.simplisafe -simplisafe-python==2023.04.0 +simplisafe-python==2023.05.0 # homeassistant.components.slack slackclient==2.5.0 From d0460d542402e4d5bd8b30dec65a736708f1b3f9 Mon Sep 17 00:00:00 2001 From: MatthewFlamm <39341281+MatthewFlamm@users.noreply.github.com> Date: Wed, 17 May 2023 16:00:13 -0400 Subject: [PATCH 391/822] Fix NWS error with no observation (#92997) Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/nws/sensor.py | 7 +++++-- tests/components/nws/test_sensor.py | 7 +++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/nws/sensor.py b/homeassistant/components/nws/sensor.py index 61f823de8e69..79a4294449b9 100644 --- a/homeassistant/components/nws/sensor.py +++ b/homeassistant/components/nws/sensor.py @@ -195,9 +195,12 @@ class NWSSensor(CoordinatorEntity[NwsDataUpdateCoordinator], SensorEntity): @property def native_value(self) -> float | None: """Return the state.""" - value = self._nws.observation.get(self.entity_description.key) - if value is None: + if ( + not (observation := self._nws.observation) + or (value := observation.get(self.entity_description.key)) is None + ): return None + # Set alias to unit property -> prevent unnecessary hasattr calls unit_of_measurement = self.native_unit_of_measurement if unit_of_measurement == UnitOfSpeed.MILES_PER_HOUR: diff --git a/tests/components/nws/test_sensor.py b/tests/components/nws/test_sensor.py index 5edae6302638..5e36c9c0717d 100644 --- a/tests/components/nws/test_sensor.py +++ b/tests/components/nws/test_sensor.py @@ -70,10 +70,13 @@ async def test_imperial_metric( assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION -async def test_none_values(hass: HomeAssistant, mock_simple_nws, no_weather) -> None: +@pytest.mark.parametrize("values", [NONE_OBSERVATION, None]) +async def test_none_values( + hass: HomeAssistant, mock_simple_nws, no_weather, values +) -> None: """Test with no values.""" instance = mock_simple_nws.return_value - instance.observation = NONE_OBSERVATION + instance.observation = values registry = er.async_get(hass) From 3f43184187c0b242644f2e225d2401258e80ba27 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 17 May 2023 15:01:13 -0500 Subject: [PATCH 392/822] Speed up signing paths (#93127) --- homeassistant/components/http/auth.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index 21627ef7e4f3..77ae80b62ffa 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -6,11 +6,13 @@ from datetime import timedelta from ipaddress import ip_address import logging import secrets +import time from typing import Any, Final from aiohttp import hdrs from aiohttp.web import Application, Request, StreamResponse, middleware import jwt +from jwt import api_jws from yarl import URL from homeassistant.auth import jwt_wrapper @@ -18,8 +20,8 @@ from homeassistant.auth.const import GROUP_ID_READ_ONLY from homeassistant.auth.models import User from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.json import json_bytes from homeassistant.helpers.storage import Store -from homeassistant.util import dt as dt_util from homeassistant.util.network import is_local from .const import KEY_AUTHENTICATED, KEY_HASS_REFRESH_TOKEN_ID, KEY_HASS_USER @@ -60,20 +62,19 @@ def async_sign_path( refresh_token_id = hass.data[STORAGE_KEY] url = URL(path) - now = dt_util.utcnow() + now_timestamp = int(time.time()) + expiration_timestamp = now_timestamp + int(expiration.total_seconds()) params = [itm for itm in url.query.items() if itm[0] not in SAFE_QUERY_PARAMS] - encoded = jwt.encode( + json_payload = json_bytes( { "iss": refresh_token_id, "path": url.path, "params": params, - "iat": now, - "exp": now + expiration, - }, - secret, - algorithm="HS256", + "iat": now_timestamp, + "exp": expiration_timestamp, + } ) - + encoded = api_jws.encode(json_payload, secret, "HS256") params.append((SIGN_QUERY_PARAM, encoded)) url = url.with_query(params) return f"{url.path}?{url.query_string}" From dad3fbf5e20d3ce084d843723355a8926f3adcb7 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 17 May 2023 22:05:44 +0200 Subject: [PATCH 393/822] Fix lingering timer in esphome (#92533) --- homeassistant/components/esphome/dashboard.py | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/esphome/dashboard.py b/homeassistant/components/esphome/dashboard.py index 7439f8946f6e..a8332f8d0400 100644 --- a/homeassistant/components/esphome/dashboard.py +++ b/homeassistant/components/esphome/dashboard.py @@ -10,7 +10,8 @@ from awesomeversion import AwesomeVersion from esphome_dashboard_api import ConfiguredDevice, ESPHomeDashboardAPI from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState -from homeassistant.core import HomeAssistant, callback +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -31,13 +32,13 @@ async def async_set_dashboard_info( """Set the dashboard info.""" url = f"http://{host}:{port}" - # Do nothing if we already have this data. - if ( - (cur_dashboard := hass.data.get(KEY_DASHBOARD)) - and cur_dashboard.addon_slug == addon_slug - and cur_dashboard.url == url - ): - return + if cur_dashboard := async_get_dashboard(hass): + if cur_dashboard.addon_slug == addon_slug and cur_dashboard.url == url: + # Do nothing if we already have this data. + return + # Clear and make way for new dashboard + await cur_dashboard.async_shutdown() + del hass.data[KEY_DASHBOARD] dashboard = ESPHomeDashboard(hass, addon_slug, url, async_get_clientsession(hass)) try: @@ -48,6 +49,11 @@ async def async_set_dashboard_info( hass.data[KEY_DASHBOARD] = dashboard + async def on_hass_stop(_: Event) -> None: + await dashboard.async_shutdown() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_hass_stop) + reloads = [ hass.config_entries.async_reload(entry.entry_id) for entry in hass.config_entries.async_entries(DOMAIN) From 6481ee3c82e1207dc19c4b73d8fed7868333655d Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 17 May 2023 22:08:49 +0200 Subject: [PATCH 394/822] Fix lingering timers in yeelight (#92118) --- homeassistant/components/yeelight/scanner.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/yeelight/scanner.py b/homeassistant/components/yeelight/scanner.py index 8eb2991c9dc1..dc4283b4a765 100644 --- a/homeassistant/components/yeelight/scanner.py +++ b/homeassistant/components/yeelight/scanner.py @@ -106,7 +106,7 @@ class YeelightScanner: await self._async_wait_connected() self._track_interval = async_track_time_interval( - self._hass, self.async_scan, DISCOVERY_INTERVAL + self._hass, self.async_scan, DISCOVERY_INTERVAL, cancel_on_shutdown=True ) self.async_scan() From ae7930c0adb00ff42c6b74cf503c93559858c6eb Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Wed, 17 May 2023 22:31:04 +0200 Subject: [PATCH 395/822] Use entity translation in dsmr (#93017) * Use entity translation in dsmr * Combine a few strings --- homeassistant/components/dsmr/sensor.py | 69 ++++++++-------- homeassistant/components/dsmr/strings.json | 96 +++++++++++++++++++++- tests/components/dsmr/test_sensor.py | 9 ++ 3 files changed, 138 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index 15c075454fc5..524f5c4ffc2a 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -80,7 +80,7 @@ class DSMRSensorEntityDescription( SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( DSMRSensorEntityDescription( key="current_electricity_usage", - name="Power consumption", + translation_key="current_electricity_usage", obis_reference=obis_references.CURRENT_ELECTRICITY_USAGE, device_class=SensorDeviceClass.POWER, force_update=True, @@ -88,7 +88,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="current_electricity_delivery", - name="Power production", + translation_key="current_electricity_delivery", obis_reference=obis_references.CURRENT_ELECTRICITY_DELIVERY, device_class=SensorDeviceClass.POWER, force_update=True, @@ -96,17 +96,16 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="electricity_active_tariff", - name="Active tariff", + translation_key="electricity_active_tariff", obis_reference=obis_references.ELECTRICITY_ACTIVE_TARIFF, dsmr_versions={"2.2", "4", "5", "5B", "5L"}, device_class=SensorDeviceClass.ENUM, options=["low", "normal"], - translation_key="electricity_tariff", icon="mdi:flash", ), DSMRSensorEntityDescription( key="electricity_used_tariff_1", - name="Energy consumption (tarif 1)", + translation_key="electricity_used_tariff_1", obis_reference=obis_references.ELECTRICITY_USED_TARIFF_1, dsmr_versions={"2.2", "4", "5", "5B", "5L"}, device_class=SensorDeviceClass.ENERGY, @@ -115,7 +114,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="electricity_used_tariff_2", - name="Energy consumption (tarif 2)", + translation_key="electricity_used_tariff_2", obis_reference=obis_references.ELECTRICITY_USED_TARIFF_2, dsmr_versions={"2.2", "4", "5", "5B", "5L"}, force_update=True, @@ -124,7 +123,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="electricity_delivered_tariff_1", - name="Energy production (tarif 1)", + translation_key="electricity_delivered_tariff_1", obis_reference=obis_references.ELECTRICITY_DELIVERED_TARIFF_1, dsmr_versions={"2.2", "4", "5", "5B", "5L"}, force_update=True, @@ -133,7 +132,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="electricity_delivered_tariff_2", - name="Energy production (tarif 2)", + translation_key="electricity_delivered_tariff_2", obis_reference=obis_references.ELECTRICITY_DELIVERED_TARIFF_2, dsmr_versions={"2.2", "4", "5", "5B", "5L"}, force_update=True, @@ -142,7 +141,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="instantaneous_active_power_l1_positive", - name="Power consumption phase L1", + translation_key="instantaneous_active_power_l1_positive", obis_reference=obis_references.INSTANTANEOUS_ACTIVE_POWER_L1_POSITIVE, device_class=SensorDeviceClass.POWER, entity_registry_enabled_default=False, @@ -150,7 +149,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="instantaneous_active_power_l2_positive", - name="Power consumption phase L2", + translation_key="instantaneous_active_power_l2_positive", obis_reference=obis_references.INSTANTANEOUS_ACTIVE_POWER_L2_POSITIVE, device_class=SensorDeviceClass.POWER, entity_registry_enabled_default=False, @@ -158,7 +157,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="instantaneous_active_power_l3_positive", - name="Power consumption phase L3", + translation_key="instantaneous_active_power_l3_positive", obis_reference=obis_references.INSTANTANEOUS_ACTIVE_POWER_L3_POSITIVE, device_class=SensorDeviceClass.POWER, entity_registry_enabled_default=False, @@ -166,7 +165,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="instantaneous_active_power_l1_negative", - name="Power production phase L1", + translation_key="instantaneous_active_power_l1_negative", obis_reference=obis_references.INSTANTANEOUS_ACTIVE_POWER_L1_NEGATIVE, device_class=SensorDeviceClass.POWER, entity_registry_enabled_default=False, @@ -174,7 +173,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="instantaneous_active_power_l2_negative", - name="Power production phase L2", + translation_key="instantaneous_active_power_l2_negative", obis_reference=obis_references.INSTANTANEOUS_ACTIVE_POWER_L2_NEGATIVE, device_class=SensorDeviceClass.POWER, entity_registry_enabled_default=False, @@ -182,7 +181,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="instantaneous_active_power_l3_negative", - name="Power production phase L3", + translation_key="instantaneous_active_power_l3_negative", obis_reference=obis_references.INSTANTANEOUS_ACTIVE_POWER_L3_NEGATIVE, device_class=SensorDeviceClass.POWER, entity_registry_enabled_default=False, @@ -190,7 +189,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="short_power_failure_count", - name="Short power failure count", + translation_key="short_power_failure_count", obis_reference=obis_references.SHORT_POWER_FAILURE_COUNT, dsmr_versions={"2.2", "4", "5", "5B", "5L"}, entity_registry_enabled_default=False, @@ -199,7 +198,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="long_power_failure_count", - name="Long power failure count", + translation_key="long_power_failure_count", obis_reference=obis_references.LONG_POWER_FAILURE_COUNT, dsmr_versions={"2.2", "4", "5", "5B", "5L"}, entity_registry_enabled_default=False, @@ -208,7 +207,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="voltage_sag_l1_count", - name="Voltage sags phase L1", + translation_key="voltage_sag_l1_count", obis_reference=obis_references.VOLTAGE_SAG_L1_COUNT, dsmr_versions={"2.2", "4", "5", "5B", "5L"}, entity_registry_enabled_default=False, @@ -216,7 +215,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="voltage_sag_l2_count", - name="Voltage sags phase L2", + translation_key="voltage_sag_l2_count", obis_reference=obis_references.VOLTAGE_SAG_L2_COUNT, dsmr_versions={"2.2", "4", "5", "5B", "5L"}, entity_registry_enabled_default=False, @@ -224,7 +223,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="voltage_sag_l3_count", - name="Voltage sags phase L3", + translation_key="voltage_sag_l3_count", obis_reference=obis_references.VOLTAGE_SAG_L3_COUNT, dsmr_versions={"2.2", "4", "5", "5B", "5L"}, entity_registry_enabled_default=False, @@ -232,7 +231,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="voltage_swell_l1_count", - name="Voltage swells phase L1", + translation_key="voltage_swell_l1_count", obis_reference=obis_references.VOLTAGE_SWELL_L1_COUNT, dsmr_versions={"2.2", "4", "5", "5B", "5L"}, entity_registry_enabled_default=False, @@ -241,7 +240,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="voltage_swell_l2_count", - name="Voltage swells phase L2", + translation_key="voltage_swell_l2_count", obis_reference=obis_references.VOLTAGE_SWELL_L2_COUNT, dsmr_versions={"2.2", "4", "5", "5B", "5L"}, entity_registry_enabled_default=False, @@ -250,7 +249,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="voltage_swell_l3_count", - name="Voltage swells phase L3", + translation_key="voltage_swell_l3_count", obis_reference=obis_references.VOLTAGE_SWELL_L3_COUNT, dsmr_versions={"2.2", "4", "5", "5B", "5L"}, entity_registry_enabled_default=False, @@ -259,7 +258,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="instantaneous_voltage_l1", - name="Voltage phase L1", + translation_key="instantaneous_voltage_l1", obis_reference=obis_references.INSTANTANEOUS_VOLTAGE_L1, device_class=SensorDeviceClass.VOLTAGE, entity_registry_enabled_default=False, @@ -268,7 +267,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="instantaneous_voltage_l2", - name="Voltage phase L2", + translation_key="instantaneous_voltage_l2", obis_reference=obis_references.INSTANTANEOUS_VOLTAGE_L2, device_class=SensorDeviceClass.VOLTAGE, entity_registry_enabled_default=False, @@ -277,7 +276,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="instantaneous_voltage_l3", - name="Voltage phase L3", + translation_key="instantaneous_voltage_l3", obis_reference=obis_references.INSTANTANEOUS_VOLTAGE_L3, device_class=SensorDeviceClass.VOLTAGE, entity_registry_enabled_default=False, @@ -286,7 +285,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="instantaneous_current_l1", - name="Current phase L1", + translation_key="instantaneous_current_l1", obis_reference=obis_references.INSTANTANEOUS_CURRENT_L1, device_class=SensorDeviceClass.CURRENT, entity_registry_enabled_default=False, @@ -295,7 +294,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="instantaneous_current_l2", - name="Current phase L2", + translation_key="instantaneous_current_l2", obis_reference=obis_references.INSTANTANEOUS_CURRENT_L2, device_class=SensorDeviceClass.CURRENT, entity_registry_enabled_default=False, @@ -304,7 +303,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="instantaneous_current_l3", - name="Current phase L3", + translation_key="instantaneous_current_l3", obis_reference=obis_references.INSTANTANEOUS_CURRENT_L3, device_class=SensorDeviceClass.CURRENT, entity_registry_enabled_default=False, @@ -313,7 +312,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="belgium_max_power_per_phase", - name="Max power per phase", + translation_key="max_power_per_phase", obis_reference=obis_references.BELGIUM_MAX_POWER_PER_PHASE, dsmr_versions={"5B"}, device_class=SensorDeviceClass.POWER, @@ -323,7 +322,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="belgium_max_current_per_phase", - name="Max current per phase", + translation_key="max_current_per_phase", obis_reference=obis_references.BELGIUM_MAX_CURRENT_PER_PHASE, dsmr_versions={"5B"}, device_class=SensorDeviceClass.POWER, @@ -333,7 +332,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="electricity_imported_total", - name="Energy consumption (total)", + translation_key="electricity_imported_total", obis_reference=obis_references.ELECTRICITY_IMPORTED_TOTAL, dsmr_versions={"5L", "5S", "Q3D"}, force_update=True, @@ -342,7 +341,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="electricity_exported_total", - name="Energy production (total)", + translation_key="electricity_exported_total", obis_reference=obis_references.ELECTRICITY_EXPORTED_TOTAL, dsmr_versions={"5L", "5S", "Q3D"}, force_update=True, @@ -351,7 +350,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="hourly_gas_meter_reading", - name="Gas consumption", + translation_key="gas_meter_reading", obis_reference=obis_references.HOURLY_GAS_METER_READING, dsmr_versions={"4", "5", "5L"}, is_gas=True, @@ -361,7 +360,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="belgium_5min_gas_meter_reading", - name="Gas consumption", + translation_key="gas_meter_reading", obis_reference=obis_references.BELGIUM_5MIN_GAS_METER_READING, dsmr_versions={"5B"}, is_gas=True, @@ -371,7 +370,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="gas_meter_reading", - name="Gas consumption", + translation_key="gas_meter_reading", obis_reference=obis_references.GAS_METER_READING, dsmr_versions={"2.2"}, is_gas=True, diff --git a/homeassistant/components/dsmr/strings.json b/homeassistant/components/dsmr/strings.json index 5db3a8bb8630..5724ad643fef 100644 --- a/homeassistant/components/dsmr/strings.json +++ b/homeassistant/components/dsmr/strings.json @@ -42,11 +42,105 @@ }, "entity": { "sensor": { - "electricity_tariff": { + "current_electricity_delivery": { + "name": "Power production" + }, + "current_electricity_usage": { + "name": "Power consumption" + }, + "electricity_active_tariff": { + "name": "Active tariff", "state": { "low": "Low", "normal": "Normal" } + }, + "electricity_delivered_tariff_1": { + "name": "Energy production (tarif 1)" + }, + "electricity_delivered_tariff_2": { + "name": "Energy production (tarif 2)" + }, + "electricity_exported_total": { + "name": "Energy production (total)" + }, + "electricity_imported_total": { + "name": "Energy consumption (total)" + }, + "electricity_used_tariff_1": { + "name": "Energy consumption (tarif 1)" + }, + "electricity_used_tariff_2": { + "name": "Energy consumption (tarif 2)" + }, + "gas_meter_reading": { + "name": "Gas consumption" + }, + "instantaneous_active_power_l1_negative": { + "name": "Power production phase L1" + }, + "instantaneous_active_power_l1_positive": { + "name": "Power consumption phase L1" + }, + "instantaneous_active_power_l2_negative": { + "name": "Power production phase L2" + }, + "instantaneous_active_power_l2_positive": { + "name": "Power consumption phase L2" + }, + "instantaneous_active_power_l3_negative": { + "name": "Power production phase L3" + }, + "instantaneous_active_power_l3_positive": { + "name": "Power consumption phase L3" + }, + "instantaneous_current_l1": { + "name": "Current phase L1" + }, + "instantaneous_current_l2": { + "name": "Current phase L2" + }, + "instantaneous_current_l3": { + "name": "Current phase L3" + }, + "instantaneous_voltage_l1": { + "name": "Voltage phase L1" + }, + "instantaneous_voltage_l2": { + "name": "Voltage phase L2" + }, + "instantaneous_voltage_l3": { + "name": "Voltage phase L3" + }, + "long_power_failure_count": { + "name": "Long power failure count" + }, + "max_current_per_phase": { + "name": "Max current per phase" + }, + "max_power_per_phase": { + "name": "Max power per phase" + }, + "short_power_failure_count": { + "name": "Short power failure count" + }, + "voltage_sag_l1_count": { + "name": "Voltage sags phase L1" + }, + "voltage_sag_l2_count": { + "name": "Voltage sags phase L2" + }, + "voltage_sag_l3_count": { + "name": "Voltage sags phase L3" + }, + "voltage_swell_l1_count": { + "name": "Voltage swells phase L1" + }, + "voltage_swell_l2_count": { + "name": "Voltage swells phase L2" + }, + "voltage_swell_l3_count": { + "name": "Voltage swells phase L3" } } }, diff --git a/tests/components/dsmr/test_sensor.py b/tests/components/dsmr/test_sensor.py index 0eebf30b5d34..ed04bda02f89 100644 --- a/tests/components/dsmr/test_sensor.py +++ b/tests/components/dsmr/test_sensor.py @@ -19,6 +19,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import ( ATTR_DEVICE_CLASS, + ATTR_FRIENDLY_NAME, ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, STATE_UNAVAILABLE, @@ -119,6 +120,10 @@ async def test_default_setup(hass: HomeAssistant, dsmr_connection_fixture) -> No assert active_tariff.state == "low" assert active_tariff.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENUM assert active_tariff.attributes.get(ATTR_ICON) == "mdi:flash" + assert ( + active_tariff.attributes.get(ATTR_FRIENDLY_NAME) + == "Electricity Meter Active tariff" + ) assert active_tariff.attributes.get(ATTR_OPTIONS) == ["low", "normal"] assert active_tariff.attributes.get(ATTR_STATE_CLASS) is None assert active_tariff.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "" @@ -127,6 +132,10 @@ async def test_default_setup(hass: HomeAssistant, dsmr_connection_fixture) -> No gas_consumption = hass.states.get("sensor.gas_meter_gas_consumption") assert gas_consumption.state == "745.695" assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS + assert ( + gas_consumption.attributes.get(ATTR_FRIENDLY_NAME) + == "Gas Meter Gas consumption" + ) assert ( gas_consumption.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL_INCREASING From 0c51de25a2f1732771c28bac78f16fdb061c9ade Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Thu, 18 May 2023 16:04:27 +0300 Subject: [PATCH 396/822] Update UpCloud config entry data on successful reconfig (#68718) * Update UpCloud config entry data on successful reconfig * Add already configured test * Improve success case request mocking So that the data fetch that might happen in the background after success won't trash the test log with misleading errors. * Simplify already configured test Thanks-to: Martin Hjelmare --- .../components/upcloud/config_flow.py | 3 ++ homeassistant/components/upcloud/strings.json | 3 ++ tests/components/upcloud/test_config_flow.py | 28 ++++++++++++++++++- 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/upcloud/config_flow.py b/homeassistant/components/upcloud/config_flow.py index e6868be29b91..fda6c1d561be 100644 --- a/homeassistant/components/upcloud/config_flow.py +++ b/homeassistant/components/upcloud/config_flow.py @@ -55,6 +55,9 @@ class UpCloudConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", user_input=user_input, errors=errors ) + self._abort_if_unique_id_configured( + updates={CONF_PASSWORD: user_input[CONF_PASSWORD]} + ) return self.async_create_entry(title=user_input[CONF_USERNAME], data=user_input) @callback diff --git a/homeassistant/components/upcloud/strings.json b/homeassistant/components/upcloud/strings.json index 3daf70be5981..fc52916e9b43 100644 --- a/homeassistant/components/upcloud/strings.json +++ b/homeassistant/components/upcloud/strings.json @@ -11,6 +11,9 @@ "password": "[%key:common::config_flow::data::password%]" } } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" } }, "options": { diff --git a/tests/components/upcloud/test_config_flow.py b/tests/components/upcloud/test_config_flow.py index 5a52bb62c277..eadbe1c8fe60 100644 --- a/tests/components/upcloud/test_config_flow.py +++ b/tests/components/upcloud/test_config_flow.py @@ -72,7 +72,8 @@ async def test_success( hass: HomeAssistant, requests_mock: requests_mock.Mocker ) -> None: """Test successful flow provides entry creation data.""" - requests_mock.request(ANY, ANY, text='{"account":{"username":"user"}}') + requests_mock.request(ANY, "/1.3/account", text='{"account":{"username":"user"}}') + requests_mock.request(ANY, "/1.3/server", text='{"servers": {"server":[]}}') result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER}, data=FIXTURE_USER_INPUT ) @@ -105,3 +106,28 @@ async def test_options(hass: HomeAssistant) -> None: assert result["data"][CONF_SCAN_INTERVAL] == int( FIXTURE_USER_INPUT_OPTIONS[CONF_SCAN_INTERVAL] ) + + +async def test_already_configured(hass, requests_mock): + """Test duplicate entry aborts and updates data.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=FIXTURE_USER_INPUT[CONF_USERNAME], + data=FIXTURE_USER_INPUT, + options=FIXTURE_USER_INPUT_OPTIONS, + ) + config_entry.add_to_hass(hass) + + new_user_input = FIXTURE_USER_INPUT.copy() + new_user_input[CONF_PASSWORD] += "_changed" + + requests_mock.request(ANY, "/1.3/account", text='{"account":{"username":"user"}}') + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=new_user_input + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + assert config_entry.data[CONF_USERNAME] == new_user_input[CONF_USERNAME] + assert config_entry.data[CONF_PASSWORD] == new_user_input[CONF_PASSWORD] From 2eef2ed911d59249b49eec17bfc0cd8b006bd336 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 18 May 2023 10:34:09 -0500 Subject: [PATCH 397/822] Add option to disable webhooks in ONVIF (#93186) --- homeassistant/components/onvif/__init__.py | 9 ++++++- homeassistant/components/onvif/config_flow.py | 14 +++++++++++ homeassistant/components/onvif/const.py | 2 ++ homeassistant/components/onvif/device.py | 25 ++++++++++++++++++- homeassistant/components/onvif/strings.json | 3 ++- tests/components/onvif/test_config_flow.py | 11 +++++--- tests/components/onvif/test_diagnostics.py | 6 ++++- 7 files changed, 63 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/onvif/__init__.py b/homeassistant/components/onvif/__init__.py index 36b4a28dffd4..a834a8f2df6d 100644 --- a/homeassistant/components/onvif/__init__.py +++ b/homeassistant/components/onvif/__init__.py @@ -20,7 +20,13 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from .const import CONF_SNAPSHOT_AUTH, DEFAULT_ARGUMENTS, DOMAIN +from .const import ( + CONF_ENABLE_WEBHOOKS, + CONF_SNAPSHOT_AUTH, + DEFAULT_ARGUMENTS, + DEFAULT_ENABLE_WEBHOOKS, + DOMAIN, +) from .device import ONVIFDevice LOGGER = logging.getLogger(__name__) @@ -143,6 +149,7 @@ async def async_populate_options(hass, entry): options = { CONF_EXTRA_ARGUMENTS: DEFAULT_ARGUMENTS, CONF_RTSP_TRANSPORT: next(iter(RTSP_TRANSPORTS)), + CONF_ENABLE_WEBHOOKS: DEFAULT_ENABLE_WEBHOOKS, } hass.config_entries.async_update_entry(entry, options=options) diff --git a/homeassistant/components/onvif/config_flow.py b/homeassistant/components/onvif/config_flow.py index da948787e495..020649db87eb 100644 --- a/homeassistant/components/onvif/config_flow.py +++ b/homeassistant/components/onvif/config_flow.py @@ -34,7 +34,9 @@ from homeassistant.helpers import device_registry as dr from .const import ( CONF_DEVICE_ID, + CONF_ENABLE_WEBHOOKS, DEFAULT_ARGUMENTS, + DEFAULT_ENABLE_WEBHOOKS, DEFAULT_PORT, DOMAIN, GET_CAPABILITIES_EXCEPTIONS, @@ -387,6 +389,12 @@ class OnvifOptionsFlowHandler(config_entries.OptionsFlow): CONF_USE_WALLCLOCK_AS_TIMESTAMPS, self.config_entry.options.get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS, False), ) + self.options[CONF_ENABLE_WEBHOOKS] = user_input.get( + CONF_ENABLE_WEBHOOKS, + self.config_entry.options.get( + CONF_ENABLE_WEBHOOKS, DEFAULT_ENABLE_WEBHOOKS + ), + ) return self.async_create_entry(title="", data=self.options) advanced_options = {} @@ -415,6 +423,12 @@ class OnvifOptionsFlowHandler(config_entries.OptionsFlow): CONF_RTSP_TRANSPORT, next(iter(RTSP_TRANSPORTS)) ), ): vol.In(RTSP_TRANSPORTS), + vol.Optional( + CONF_ENABLE_WEBHOOKS, + default=self.config_entry.options.get( + CONF_ENABLE_WEBHOOKS, DEFAULT_ENABLE_WEBHOOKS + ), + ): bool, **advanced_options, } ), diff --git a/homeassistant/components/onvif/const.py b/homeassistant/components/onvif/const.py index bfe22eacbd5e..8d95ef484bf9 100644 --- a/homeassistant/components/onvif/const.py +++ b/homeassistant/components/onvif/const.py @@ -14,6 +14,8 @@ DEFAULT_ARGUMENTS = "-pred 1" CONF_DEVICE_ID = "deviceid" CONF_SNAPSHOT_AUTH = "snapshot_auth" +CONF_ENABLE_WEBHOOKS = "enable_webhooks" +DEFAULT_ENABLE_WEBHOOKS = True ATTR_PAN = "pan" ATTR_TILT = "tilt" diff --git a/homeassistant/components/onvif/device.py b/homeassistant/components/onvif/device.py index 1152503a718f..e470b3c700bf 100644 --- a/homeassistant/components/onvif/device.py +++ b/homeassistant/components/onvif/device.py @@ -28,7 +28,9 @@ import homeassistant.util.dt as dt_util from .const import ( ABSOLUTE_MOVE, + CONF_ENABLE_WEBHOOKS, CONTINUOUS_MOVE, + DEFAULT_ENABLE_WEBHOOKS, GET_CAPABILITIES_EXCEPTIONS, GOTOPRESET_MOVE, LOGGER, @@ -52,6 +54,7 @@ class ONVIFDevice: """Initialize the device.""" self.hass: HomeAssistant = hass self.config_entry: ConfigEntry = config_entry + self._original_options = dict(config_entry.options) self.available: bool = True self.info: DeviceInfo = DeviceInfo() @@ -63,6 +66,13 @@ class ONVIFDevice: self._dt_diff_seconds: float = 0 + async def _async_update_listener( + self, hass: HomeAssistant, entry: ConfigEntry + ) -> None: + """Handle options update.""" + if self._original_options != entry.options: + hass.async_create_task(hass.config_entries.async_reload(entry.entry_id)) + @property def name(self) -> str: """Return the name of this device.""" @@ -151,6 +161,14 @@ class ONVIFDevice: self.capabilities.events = await self.async_start_events() LOGGER.debug("Camera %s capabilities = %s", self.name, self.capabilities) + # Bind the listener to the ONVIFDevice instance since + # async_update_listener only creates a weak reference to the listener + # and we need to make sure it doesn't get garbage collected since only + # the ONVIFDevice instance is stored in hass.data + self.config_entry.async_on_unload( + self.config_entry.add_update_listener(self._async_update_listener) + ) + async def async_stop(self, event=None): """Shut it all down.""" if self.events: @@ -357,7 +375,12 @@ class ONVIFDevice: "WSPullPointSupport" ) LOGGER.debug("%s: WSPullPointSupport: %s", self.name, pull_point_support) - return await self.events.async_start(pull_point_support is not False, True) + return await self.events.async_start( + pull_point_support is not False, + self.config_entry.options.get( + CONF_ENABLE_WEBHOOKS, DEFAULT_ENABLE_WEBHOOKS + ), + ) return False diff --git a/homeassistant/components/onvif/strings.json b/homeassistant/components/onvif/strings.json index 3e9db0b3c7e6..8e989f1dfa09 100644 --- a/homeassistant/components/onvif/strings.json +++ b/homeassistant/components/onvif/strings.json @@ -61,7 +61,8 @@ "data": { "extra_arguments": "Extra FFMPEG arguments", "rtsp_transport": "RTSP transport mechanism", - "use_wallclock_as_timestamps": "Use wall clock as timestamps" + "use_wallclock_as_timestamps": "Use wall clock as timestamps", + "enable_webhooks": "Enable Webhooks" }, "title": "ONVIF Device Options" } diff --git a/tests/components/onvif/test_config_flow.py b/tests/components/onvif/test_config_flow.py index 8187a427be9e..981620e7ae0c 100644 --- a/tests/components/onvif/test_config_flow.py +++ b/tests/components/onvif/test_config_flow.py @@ -1,6 +1,8 @@ """Test ONVIF config flow.""" from unittest.mock import MagicMock, patch +import pytest + from homeassistant import config_entries, data_entry_flow from homeassistant.components import dhcp from homeassistant.components.onvif import DOMAIN, config_flow @@ -597,7 +599,8 @@ async def test_flow_manual_entry_wrong_password(hass: HomeAssistant) -> None: } -async def test_option_flow(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("option_value", [True, False]) +async def test_option_flow(hass: HomeAssistant, option_value: bool) -> None: """Test config flow options.""" entry, _, _ = await setup_onvif_integration(hass) @@ -613,7 +616,8 @@ async def test_option_flow(hass: HomeAssistant) -> None: user_input={ config_flow.CONF_EXTRA_ARGUMENTS: "", config_flow.CONF_RTSP_TRANSPORT: list(config_flow.RTSP_TRANSPORTS)[1], - config_flow.CONF_USE_WALLCLOCK_AS_TIMESTAMPS: True, + config_flow.CONF_USE_WALLCLOCK_AS_TIMESTAMPS: option_value, + config_flow.CONF_ENABLE_WEBHOOKS: option_value, }, ) @@ -621,7 +625,8 @@ async def test_option_flow(hass: HomeAssistant) -> None: assert result["data"] == { config_flow.CONF_EXTRA_ARGUMENTS: "", config_flow.CONF_RTSP_TRANSPORT: list(config_flow.RTSP_TRANSPORTS)[1], - config_flow.CONF_USE_WALLCLOCK_AS_TIMESTAMPS: True, + config_flow.CONF_USE_WALLCLOCK_AS_TIMESTAMPS: option_value, + config_flow.CONF_ENABLE_WEBHOOKS: option_value, } diff --git a/tests/components/onvif/test_diagnostics.py b/tests/components/onvif/test_diagnostics.py index 2ab2deb6884f..eb7d058a7552 100644 --- a/tests/components/onvif/test_diagnostics.py +++ b/tests/components/onvif/test_diagnostics.py @@ -39,7 +39,11 @@ async def test_diagnostics( "password": "**REDACTED**", "snapshot_auth": "digest", }, - "options": {"extra_arguments": "-pred 1", "rtsp_transport": "tcp"}, + "options": { + "extra_arguments": "-pred 1", + "rtsp_transport": "tcp", + "enable_webhooks": True, + }, "pref_disable_new_entities": False, "pref_disable_polling": False, "source": "user", From 763b89862114f5fecc1f7d45199dc97dd4404736 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 18 May 2023 10:48:04 -0500 Subject: [PATCH 398/822] Add always connected option to Yale Access Bluetooth (#93224) * Add always connected option to Yale Access Bluetooth If the lock does not support push updates via advertisements or you want lock operation to be more responsive, you can enable always connected mode. Always connected will cause the lock to stay connected to Home Assistant via Bluetooth, which will use more battery. * Update homeassistant/components/yalexs_ble/config_flow.py Co-authored-by: Martin Hjelmare --------- Co-authored-by: Martin Hjelmare --- .../components/yalexs_ble/__init__.py | 20 ++++-- .../components/yalexs_ble/config_flow.py | 46 ++++++++++++- homeassistant/components/yalexs_ble/const.py | 1 + homeassistant/components/yalexs_ble/models.py | 1 + .../components/yalexs_ble/strings.json | 10 +++ .../components/yalexs_ble/test_config_flow.py | 67 ++++++++++++++++++- 6 files changed, 138 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/yalexs_ble/__init__.py b/homeassistant/components/yalexs_ble/__init__.py index 4e9b75137459..11516015b6c0 100644 --- a/homeassistant/components/yalexs_ble/__init__.py +++ b/homeassistant/components/yalexs_ble/__init__.py @@ -19,7 +19,14 @@ from homeassistant.const import CONF_ADDRESS, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from .const import CONF_KEY, CONF_LOCAL_NAME, CONF_SLOT, DEVICE_TIMEOUT, DOMAIN +from .const import ( + CONF_ALWAYS_CONNECTED, + CONF_KEY, + CONF_LOCAL_NAME, + CONF_SLOT, + DEVICE_TIMEOUT, + DOMAIN, +) from .models import YaleXSBLEData from .util import async_find_existing_service_info, bluetooth_callback_matcher @@ -33,7 +40,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: key = entry.data[CONF_KEY] slot = entry.data[CONF_SLOT] has_unique_local_name = local_name_is_unique(local_name) - push_lock = PushLock(local_name, address, None, key, slot) + always_connected = entry.options.get(CONF_ALWAYS_CONNECTED, False) + push_lock = PushLock( + local_name, address, None, key, slot, always_connected=always_connected + ) id_ = local_name if has_unique_local_name else address push_lock.set_name(f"{entry.title} ({id_})") @@ -79,7 +89,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) from ex hass.data.setdefault(DOMAIN, {})[entry.entry_id] = YaleXSBLEData( - entry.title, push_lock + entry.title, push_lock, always_connected ) @callback @@ -115,7 +125,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" data: YaleXSBLEData = hass.data[DOMAIN][entry.entry_id] - if entry.title != data.title: + if entry.title != data.title or data.always_connected != entry.options.get( + CONF_ALWAYS_CONNECTED + ): await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/yalexs_ble/config_flow.py b/homeassistant/components/yalexs_ble/config_flow.py index b87066b50fe8..3a6d91c4f552 100644 --- a/homeassistant/components/yalexs_ble/config_flow.py +++ b/homeassistant/components/yalexs_ble/config_flow.py @@ -23,10 +23,11 @@ from homeassistant.components.bluetooth import ( async_discovered_service_info, ) from homeassistant.const import CONF_ADDRESS +from homeassistant.core import callback from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.helpers.typing import DiscoveryInfoType -from .const import CONF_KEY, CONF_LOCAL_NAME, CONF_SLOT, DOMAIN +from .const import CONF_ALWAYS_CONNECTED, CONF_KEY, CONF_LOCAL_NAME, CONF_SLOT, DOMAIN from .util import async_find_existing_service_info, human_readable_name _LOGGER = logging.getLogger(__name__) @@ -297,3 +298,46 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): data_schema=data_schema, errors=errors, ) + + @staticmethod + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> YaleXSBLEOptionsFlowHandler: + """Get the options flow for this handler.""" + return YaleXSBLEOptionsFlowHandler(config_entry) + + +class YaleXSBLEOptionsFlowHandler(config_entries.OptionsFlow): + """Handle YaleXSBLE options.""" + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Initialize YaleXSBLE options flow.""" + self.entry = config_entry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage the YaleXSBLE options.""" + return await self.async_step_device_options() + + async def async_step_device_options( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage the YaleXSBLE devices options.""" + if user_input is not None: + return self.async_create_entry( + data={CONF_ALWAYS_CONNECTED: user_input[CONF_ALWAYS_CONNECTED]}, + ) + + return self.async_show_form( + step_id="device_options", + data_schema=vol.Schema( + { + vol.Optional( + CONF_ALWAYS_CONNECTED, + default=self.entry.options.get(CONF_ALWAYS_CONNECTED, False), + ): bool, + } + ), + ) diff --git a/homeassistant/components/yalexs_ble/const.py b/homeassistant/components/yalexs_ble/const.py index f38a376a7170..18555f91075c 100644 --- a/homeassistant/components/yalexs_ble/const.py +++ b/homeassistant/components/yalexs_ble/const.py @@ -5,5 +5,6 @@ DOMAIN = "yalexs_ble" CONF_LOCAL_NAME = "local_name" CONF_KEY = "key" CONF_SLOT = "slot" +CONF_ALWAYS_CONNECTED = "always_connected" DEVICE_TIMEOUT = 55 diff --git a/homeassistant/components/yalexs_ble/models.py b/homeassistant/components/yalexs_ble/models.py index d79668f1c70b..3b83b52cf739 100644 --- a/homeassistant/components/yalexs_ble/models.py +++ b/homeassistant/components/yalexs_ble/models.py @@ -12,3 +12,4 @@ class YaleXSBLEData: title: str lock: PushLock + always_connected: bool diff --git a/homeassistant/components/yalexs_ble/strings.json b/homeassistant/components/yalexs_ble/strings.json index c2d1a2155c3a..bd96e07f6bac 100644 --- a/homeassistant/components/yalexs_ble/strings.json +++ b/homeassistant/components/yalexs_ble/strings.json @@ -35,5 +35,15 @@ "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "options": { + "step": { + "device_options": { + "description": "If the lock does not support push updates via advertisements or you want lock operation to be more responsive, you can enable always connected mode. Always connected will cause the lock to stay connected to Home Assistant via Bluetooth, which will use more battery.", + "data": { + "always_connected": "Always connected" + } + } + } } } diff --git a/tests/components/yalexs_ble/test_config_flow.py b/tests/components/yalexs_ble/test_config_flow.py index a0b8dfb68623..2df37a72b708 100644 --- a/tests/components/yalexs_ble/test_config_flow.py +++ b/tests/components/yalexs_ble/test_config_flow.py @@ -1,13 +1,14 @@ """Test the Yale Access Bluetooth config flow.""" import asyncio -from unittest.mock import patch +from unittest.mock import AsyncMock, Mock, patch from bleak import BleakError import pytest -from yalexs_ble import AuthError +from yalexs_ble import AuthError, DoorStatus, LockInfo, LockState, LockStatus from homeassistant import config_entries from homeassistant.components.yalexs_ble.const import ( + CONF_ALWAYS_CONNECTED, CONF_KEY, CONF_LOCAL_NAME, CONF_SLOT, @@ -27,6 +28,23 @@ from . import ( from tests.common import MockConfigEntry +def _get_mock_push_lock(): + """Return a mock PushLock.""" + mock_push_lock = Mock() + mock_push_lock.start = AsyncMock() + mock_push_lock.wait_for_first_update = AsyncMock() + mock_push_lock.stop = AsyncMock() + mock_push_lock.lock_state = LockState( + LockStatus.UNLOCKED, DoorStatus.CLOSED, None, None + ) + mock_push_lock.lock_status = LockStatus.UNLOCKED + mock_push_lock.door_status = DoorStatus.CLOSED + mock_push_lock.lock_info = LockInfo("Front Door", "M1XXX012LU", "1.0.0", "1.0.0") + mock_push_lock.device_info = None + mock_push_lock.address = YALE_ACCESS_LOCK_DISCOVERY_INFO.address + return mock_push_lock + + @pytest.mark.parametrize("slot", [0, 1, 66]) async def test_user_step_success(hass: HomeAssistant, slot: int) -> None: """Test user step success path.""" @@ -947,3 +965,48 @@ async def test_reauth(hass: HomeAssistant) -> None: assert result3["type"] == FlowResultType.ABORT assert result3["reason"] == "reauth_successful" assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_options(hass: HomeAssistant) -> None: + """Test options.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_LOCAL_NAME: YALE_ACCESS_LOCK_DISCOVERY_INFO.name, + CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address, + CONF_KEY: "2fd51b8621c6a139eaffbedcb846b60f", + CONF_SLOT: 66, + }, + unique_id=YALE_ACCESS_LOCK_DISCOVERY_INFO.address, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.yalexs_ble.PushLock", + return_value=_get_mock_push_lock(), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init( + entry.entry_id, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "device_options" + + with patch( + "homeassistant.components.yalexs_ble.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + { + CONF_ALWAYS_CONNECTED: True, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert entry.options == {CONF_ALWAYS_CONNECTED: True} + assert len(mock_setup_entry.mock_calls) == 1 From 0fca90127bd0c59d811648c27f41bd90f5c70200 Mon Sep 17 00:00:00 2001 From: Andre Lengwenus Date: Thu, 18 May 2023 18:26:37 +0200 Subject: [PATCH 399/822] Bump pypck to 0.7.17 (#93234) --- homeassistant/components/lcn/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lcn/manifest.json b/homeassistant/components/lcn/manifest.json index dcc65010e7d7..6153ecf45409 100644 --- a/homeassistant/components/lcn/manifest.json +++ b/homeassistant/components/lcn/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/lcn", "iot_class": "local_push", "loggers": ["pypck"], - "requirements": ["pypck==0.7.16"] + "requirements": ["pypck==0.7.17"] } diff --git a/requirements_all.txt b/requirements_all.txt index f4714dd15fca..7f518fac4b11 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1877,7 +1877,7 @@ pyownet==0.10.0.post1 pypca==0.0.7 # homeassistant.components.lcn -pypck==0.7.16 +pypck==0.7.17 # homeassistant.components.pjlink pypjlink2==1.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1372b8351492..bb6eb9c0e601 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1381,7 +1381,7 @@ pyowm==3.2.0 pyownet==0.10.0.post1 # homeassistant.components.lcn -pypck==0.7.16 +pypck==0.7.17 # homeassistant.components.pjlink pypjlink2==1.2.1 From 3abcffe3a08f4f5f77ada2802f71ec716a3cad35 Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Thu, 18 May 2023 18:28:17 +0200 Subject: [PATCH 400/822] Raise on reauth in devolo Home Network switch platform (#92850) --- .../components/devolo_home_network/switch.py | 11 +++++++++-- .../components/devolo_home_network/test_switch.py | 15 +++++++++------ 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/devolo_home_network/switch.py b/homeassistant/components/devolo_home_network/switch.py index 8f82cf8f416f..e7bcee3f2ece 100644 --- a/homeassistant/components/devolo_home_network/switch.py +++ b/homeassistant/components/devolo_home_network/switch.py @@ -13,6 +13,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -113,8 +114,11 @@ class DevoloSwitchEntity(DevoloCoordinatorEntity[_DataT], SwitchEntity): """Turn the entity on.""" try: await self.entity_description.turn_on_func(self.device) - except DevicePasswordProtected: + 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" + ) from ex except DeviceUnavailable: pass # The coordinator will handle this await self.coordinator.async_request_refresh() @@ -123,8 +127,11 @@ class DevoloSwitchEntity(DevoloCoordinatorEntity[_DataT], SwitchEntity): """Turn the entity off.""" try: await self.entity_description.turn_off_func(self.device) - except DevicePasswordProtected: + 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" + ) from ex except DeviceUnavailable: pass # The coordinator will handle this await self.coordinator.async_request_refresh() diff --git a/tests/components/devolo_home_network/test_switch.py b/tests/components/devolo_home_network/test_switch.py index 257ccfbb6e31..47e09a034ca7 100644 --- a/tests/components/devolo_home_network/test_switch.py +++ b/tests/components/devolo_home_network/test_switch.py @@ -21,6 +21,7 @@ from homeassistant.const import ( EntityCategory, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.update_coordinator import REQUEST_REFRESH_DEFAULT_COOLDOWN from homeassistant.util import dt @@ -300,9 +301,10 @@ async def test_auth_failed( api = getattr(mock_device.device, set_method) api.side_effect = DevicePasswordProtected - await hass.services.async_call( - PLATFORM, SERVICE_TURN_ON, {"entity_id": state_key}, blocking=True - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + PLATFORM, SERVICE_TURN_ON, {"entity_id": state_key}, blocking=True + ) flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 @@ -313,9 +315,10 @@ async def test_auth_failed( assert flow["context"]["source"] == SOURCE_REAUTH assert flow["context"]["entry_id"] == entry.entry_id - await hass.services.async_call( - PLATFORM, SERVICE_TURN_OFF, {"entity_id": state_key}, blocking=True - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + PLATFORM, SERVICE_TURN_OFF, {"entity_id": state_key}, blocking=True + ) flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 From cb22154b368e776067654eda5b7919044e9a791b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Thu, 18 May 2023 21:41:13 +0300 Subject: [PATCH 401/822] Capture warnings.warn and friends messages in logs (#85875) The default behavior of these warnings is to go to stderr, which in some setups goes easily unnoticed. For example in Docker based ones, they end up only in the container logs, and not e.g. in the HA log. Capture these to make them available in logs where other such messages are, and to make them subject to filtering as usual. https://docs.python.org/3/library/logging.html#logging.captureWarnings --- homeassistant/bootstrap.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 2077274be55f..46bd4b5d881e 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -380,6 +380,11 @@ def async_enable_logging( # formatting. If the above succeeds, this will result in a no-op. logging.basicConfig(format=fmt, datefmt=datefmt, level=logging.INFO) + # Capture warnings.warn(...) and friends messages in logs. + # The standard destination for them is stderr, which may end up unnoticed. + # This way they're where other messages are, and can be filtered as usual. + logging.captureWarnings(True) + # Suppress overly verbose logs from libraries that aren't helpful logging.getLogger("requests").setLevel(logging.WARNING) logging.getLogger("urllib3").setLevel(logging.WARNING) From 3cdcfb1654d4a43782c422a3bb4e8485a4c3307f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20=C4=8Cerm=C3=A1k?= Date: Thu, 18 May 2023 21:15:39 +0200 Subject: [PATCH 402/822] Bump rapt-ble to v0.1.1 (#93242) --- homeassistant/components/rapt_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/rapt_ble/manifest.json b/homeassistant/components/rapt_ble/manifest.json index c144251960b4..d3eab0641a62 100644 --- a/homeassistant/components/rapt_ble/manifest.json +++ b/homeassistant/components/rapt_ble/manifest.json @@ -16,5 +16,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/rapt_ble", "iot_class": "local_push", - "requirements": ["rapt-ble==0.1.0"] + "requirements": ["rapt-ble==0.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7f518fac4b11..93a22f3a149b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2233,7 +2233,7 @@ radiotherm==2.1.0 raincloudy==0.0.7 # homeassistant.components.rapt_ble -rapt-ble==0.1.0 +rapt-ble==0.1.1 # homeassistant.components.raspyrfm raspyrfm-client==1.2.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bb6eb9c0e601..bcedff42ac1b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1617,7 +1617,7 @@ radios==0.1.1 radiotherm==2.1.0 # homeassistant.components.rapt_ble -rapt-ble==0.1.0 +rapt-ble==0.1.1 # homeassistant.components.rainmachine regenmaschine==2023.05.1 From be08bb5ba685e40f18e1a634f6051eb9fbedd871 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 18 May 2023 16:32:12 -0500 Subject: [PATCH 403/822] Convert proximity to be async (#93241) --- .../components/proximity/__init__.py | 30 +++++++++++-------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/proximity/__init__.py b/homeassistant/components/proximity/__init__.py index edd501465593..0567c551d981 100644 --- a/homeassistant/components/proximity/__init__.py +++ b/homeassistant/components/proximity/__init__.py @@ -13,10 +13,10 @@ from homeassistant.const import ( CONF_ZONE, UnitOfLength, ) -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant, State, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -from homeassistant.helpers.event import track_state_change +from homeassistant.helpers.event import async_track_state_change from homeassistant.helpers.typing import ConfigType from homeassistant.util.location import distance from homeassistant.util.unit_conversion import DistanceConverter @@ -62,7 +62,8 @@ CONFIG_SCHEMA = vol.Schema( ) -def setup_proximity_component( +@callback +def async_setup_proximity_component( hass: HomeAssistant, name: str, config: ConfigType ) -> bool: """Set up the individual proximity component.""" @@ -89,17 +90,19 @@ def setup_proximity_component( ) proximity.entity_id = f"{DOMAIN}.{proximity_zone}" - proximity.schedule_update_ha_state() + proximity.async_write_ha_state() - track_state_change(hass, proximity_devices, proximity.check_proximity_state_change) + async_track_state_change( + hass, proximity_devices, proximity.async_check_proximity_state_change + ) return True -def setup(hass: HomeAssistant, config: ConfigType) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Get the zones and offsets from configuration.yaml.""" for zone, proximity_config in config[DOMAIN].items(): - setup_proximity_component(hass, zone, proximity_config) + async_setup_proximity_component(hass, zone, proximity_config) return True @@ -152,7 +155,8 @@ class Proximity(Entity): """Return the state attributes.""" return {ATTR_DIR_OF_TRAVEL: self.dir_of_travel, ATTR_NEAREST: self.nearest} - def check_proximity_state_change( + @callback + def async_check_proximity_state_change( self, entity: str, old_state: State | None, new_state: State | None ) -> None: """Perform the proximity checking.""" @@ -192,7 +196,7 @@ class Proximity(Entity): self.dist_to = "not set" self.dir_of_travel = "not set" self.nearest = "not set" - self.schedule_update_ha_state() + self.async_write_ha_state() return # At least one device is in the monitored zone so update the entity. @@ -200,7 +204,7 @@ class Proximity(Entity): self.dist_to = 0 self.dir_of_travel = "arrived" self.nearest = devices_in_zone - self.schedule_update_ha_state() + self.async_write_ha_state() return # We can't check proximity because latitude and longitude don't exist. @@ -254,7 +258,7 @@ class Proximity(Entity): device_state = self.hass.states.get(closest_device) assert device_state self.nearest = device_state.name - self.schedule_update_ha_state() + self.async_write_ha_state() return # Stop if we cannot calculate the direction of travel (i.e. we don't @@ -263,7 +267,7 @@ class Proximity(Entity): self.dist_to = round(distances_to_zone[entity]) self.dir_of_travel = "unknown" self.nearest = entity_name - self.schedule_update_ha_state() + self.async_write_ha_state() return # Reset the variables @@ -299,7 +303,7 @@ class Proximity(Entity): ) self.dir_of_travel = direction_of_travel self.nearest = entity_name - self.schedule_update_ha_state() + self.async_write_ha_state() _LOGGER.debug( "proximity.%s update entity: distance=%s: direction=%s: device=%s", self.friendly_name, From aebded049bb9972810d04b48f70b2c304b602efe Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 18 May 2023 16:32:24 -0500 Subject: [PATCH 404/822] Mark oralb devices as sleepy (#93250) --- homeassistant/components/oralb/sensor.py | 17 ++++++++++ tests/components/oralb/test_sensor.py | 41 ++++++++++++++++++++++-- 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/oralb/sensor.py b/homeassistant/components/oralb/sensor.py index 4a63fccb88c3..76104c75164c 100644 --- a/homeassistant/components/oralb/sensor.py +++ b/homeassistant/components/oralb/sensor.py @@ -124,3 +124,20 @@ class OralBBluetoothSensorEntity( def native_value(self) -> str | int | None: """Return the native value.""" return self.processor.entity_data.get(self.entity_key) + + @property + def available(self) -> bool: + """Return True if entity is available. + + The sensor is only created when the device is seen. + + Since these are sleepy devices which stop broadcasting + when not in use, we can't rely on the last update time + so once we have seen the device we always return True. + """ + return True + + @property + def assumed_state(self) -> bool: + """Return True if the device is no longer broadcasting.""" + return not self.processor.available diff --git a/tests/components/oralb/test_sensor.py b/tests/components/oralb/test_sensor.py index 8c7bacce234b..d43997fe7ed7 100644 --- a/tests/components/oralb/test_sensor.py +++ b/tests/components/oralb/test_sensor.py @@ -1,8 +1,17 @@ """Test the OralB sensors.""" +from datetime import timedelta +import time +from unittest.mock import patch + +from homeassistant.components.bluetooth import ( + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, + async_address_present, +) from homeassistant.components.oralb.const import DOMAIN -from homeassistant.const import ATTR_FRIENDLY_NAME +from homeassistant.const import ATTR_ASSUMED_STATE, ATTR_FRIENDLY_NAME from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util from . import ( ORALB_IO_SERIES_4_SERVICE_INFO, @@ -10,10 +19,11 @@ from . import ( ORALB_SERVICE_INFO, ) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed from tests.components.bluetooth import ( inject_bluetooth_service_info, inject_bluetooth_service_info_bleak, + patch_all_discovered_devices, ) @@ -44,6 +54,7 @@ async def test_sensors( toothbrush_sensor_attrs[ATTR_FRIENDLY_NAME] == "Smart Series 7000 48BE Toothbrush State" ) + assert ATTR_ASSUMED_STATE not in toothbrush_sensor_attrs assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() @@ -53,6 +64,8 @@ async def test_sensors_io_series_4( hass: HomeAssistant, entity_registry_enabled_by_default: None ) -> None: """Test setting up creates the sensors with an io series 4.""" + start_monotonic = time.monotonic() + entry = MockConfigEntry( domain=DOMAIN, unique_id=ORALB_IO_SERIES_4_SERVICE_INFO.address, @@ -71,6 +84,30 @@ async def test_sensors_io_series_4( toothbrush_sensor_attrs = toothbrush_sensor.attributes assert toothbrush_sensor.state == "gum care" assert toothbrush_sensor_attrs[ATTR_FRIENDLY_NAME] == "IO Series 4 48BE Mode" + assert ATTR_ASSUMED_STATE not in toothbrush_sensor_attrs + + # Fast-forward time without BLE advertisements + monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 + + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=monotonic_now, + ), patch_all_discovered_devices([]): + async_fire_time_changed( + hass, + dt_util.utcnow() + + timedelta(seconds=FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1), + ) + await hass.async_block_till_done() + assert ( + async_address_present(hass, ORALB_IO_SERIES_4_SERVICE_INFO.address) is False + ) + + toothbrush_sensor = hass.states.get("sensor.io_series_4_48be_mode") + # Sleepy devices should keep their state over time + assert toothbrush_sensor.state == "gum care" + toothbrush_sensor_attrs = toothbrush_sensor.attributes + assert toothbrush_sensor_attrs[ATTR_ASSUMED_STATE] is True assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() From 0ce1117287f310cedeeef486366f704b0817ac4f Mon Sep 17 00:00:00 2001 From: Luke Date: Thu, 18 May 2023 23:55:39 -0400 Subject: [PATCH 405/822] Bump Roborock to 17.0 adding device specific support and bugfixes (#92547) * init commit * use official version release * remove options * moved first refresh to gather * add extra tests * remove model_sepcification * remove old mqtt test * bump to 13.4 * fix dndtimer * bump to 14.1 * add status back * bump to 17.0 * remove error as it is not used * addressing mr comments * making enum access use get() * add check for empty hass data --- homeassistant/components/roborock/__init__.py | 69 ++++++++++----- .../components/roborock/coordinator.py | 67 +++++---------- homeassistant/components/roborock/device.py | 36 ++++---- .../components/roborock/manifest.json | 2 +- homeassistant/components/roborock/models.py | 2 +- homeassistant/components/roborock/select.py | 56 ++++++------- .../components/roborock/strings.json | 3 +- homeassistant/components/roborock/vacuum.py | 83 ++++++++----------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/roborock/mock_data.py | 11 ++- tests/components/roborock/test_init.py | 23 ----- tests/components/roborock/test_vacuum.py | 9 +- 13 files changed, 161 insertions(+), 204 deletions(-) diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index 1ea5e4734bb6..1a308f9dff94 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -7,8 +7,7 @@ import logging from roborock.api import RoborockApiClient from roborock.cloud_api import RoborockMqttClient -from roborock.containers import HomeDataDevice, RoborockDeviceInfo, UserData -from roborock.exceptions import RoborockException +from roborock.containers import DeviceData, HomeDataDevice, UserData from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_USERNAME @@ -32,39 +31,58 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.debug("Getting home data") home_data = await api_client.get_home_data(user_data) _LOGGER.debug("Got home data %s", home_data) - devices: list[HomeDataDevice] = home_data.devices + home_data.received_devices + device_map: dict[str, HomeDataDevice] = { + device.duid: device for device in home_data.devices + home_data.received_devices + } + product_info = {product.id: product for product in home_data.products} # Create a mqtt_client, which is needed to get the networking information of the device for local connection and in the future, get the map. - mqtt_client = RoborockMqttClient( - user_data, {device.duid: RoborockDeviceInfo(device) for device in devices} - ) + mqtt_clients = [ + RoborockMqttClient( + user_data, DeviceData(device, product_info[device.product_id].model) + ) + for device in device_map.values() + ] network_results = await asyncio.gather( - *(mqtt_client.get_networking(device.duid) for device in devices) + *(mqtt_client.get_networking() for mqtt_client in mqtt_clients) ) network_info = { device.duid: result - for device, result in zip(devices, network_results) + for device, result in zip(device_map.values(), network_results) if result is not None } - try: - await mqtt_client.async_disconnect() - except RoborockException as err: - _LOGGER.warning("Failed disconnecting from the mqtt server %s", err) + await asyncio.gather( + *(mqtt_client.async_disconnect() for mqtt_client in mqtt_clients), + return_exceptions=True, + ) if not network_info: raise ConfigEntryNotReady( "Could not get network information about your devices" ) - - product_info = {product.id: product for product in home_data.products} - coordinator = RoborockDataUpdateCoordinator( - hass, - devices, - network_info, - product_info, + coordinator_map: dict[str, RoborockDataUpdateCoordinator] = {} + for device_id, device in device_map.items(): + coordinator_map[device_id] = RoborockDataUpdateCoordinator( + hass, + device, + network_info[device_id], + product_info[device.product_id], + ) + # If one device update fails - we still want to set up other devices + await asyncio.gather( + *( + coordinator.async_config_entry_first_refresh() + for coordinator in coordinator_map.values() + ), + return_exceptions=True, ) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { + device_id: coordinator + for device_id, coordinator in coordinator_map.items() + if coordinator.last_update_success + } # Only add coordinators that succeeded - await coordinator.async_config_entry_first_refresh() - - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + if not hass.data[DOMAIN][entry.entry_id]: + # Don't start if no coordinators succeeded. + raise ConfigEntryNotReady("There are no devices that can currently be reached.") await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -75,7 +93,12 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Handle removal of an entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: - await hass.data[DOMAIN][entry.entry_id].release() + await asyncio.gather( + *( + coordinator.release() + for coordinator in hass.data[DOMAIN][entry.entry_id].values() + ) + ) hass.data[DOMAIN].pop(entry.entry_id) return unload_ok diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index 433b46d2899d..acaa2bfa3f25 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -1,19 +1,13 @@ """Roborock Coordinator.""" from __future__ import annotations -import asyncio from datetime import timedelta import logging -from roborock.containers import ( - HomeDataDevice, - HomeDataProduct, - NetworkInfo, - RoborockLocalDeviceInfo, -) +from roborock.containers import DeviceData, HomeDataDevice, HomeDataProduct, NetworkInfo from roborock.exceptions import RoborockException from roborock.local_api import RoborockLocalClient -from roborock.typing import DeviceProp +from roborock.roborock_typing import DeviceProp from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -26,61 +20,44 @@ SCAN_INTERVAL = timedelta(seconds=30) _LOGGER = logging.getLogger(__name__) -class RoborockDataUpdateCoordinator(DataUpdateCoordinator[dict[str, DeviceProp]]): +class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): """Class to manage fetching data from the API.""" def __init__( self, hass: HomeAssistant, - devices: list[HomeDataDevice], - devices_networking: dict[str, NetworkInfo], - product_info: dict[str, HomeDataProduct], + device: HomeDataDevice, + device_networking: NetworkInfo, + product_info: HomeDataProduct, ) -> None: """Initialize.""" super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) - local_devices_info: dict[str, RoborockLocalDeviceInfo] = {} - hass_devices_info: dict[str, RoborockHassDeviceInfo] = {} - for device in devices: - if not (networking := devices_networking.get(device.duid)): - _LOGGER.warning("Device %s is offline and cannot be setup", device.duid) - continue - hass_devices_info[device.duid] = RoborockHassDeviceInfo( - device, - networking, - product_info[device.product_id], - DeviceProp(), - ) - local_devices_info[device.duid] = RoborockLocalDeviceInfo( - device, networking - ) - self.api = RoborockLocalClient(local_devices_info) - self.devices_info = hass_devices_info + self.device_info = RoborockHassDeviceInfo( + device, + device_networking, + product_info, + DeviceProp(), + ) + device_info = DeviceData(device, product_info.model, device_networking.ip) + self.api = RoborockLocalClient(device_info) async def release(self) -> None: """Disconnect from API.""" await self.api.async_disconnect() - async def _update_device_prop(self, device_info: RoborockHassDeviceInfo) -> None: + async def _update_device_prop(self) -> None: """Update device properties.""" - device_prop = await self.api.get_prop(device_info.device.duid) + device_prop = await self.api.get_prop() if device_prop: - if device_info.props: - device_info.props.update(device_prop) + if self.device_info.props: + self.device_info.props.update(device_prop) else: - device_info.props = device_prop + self.device_info.props = device_prop - async def _async_update_data(self) -> dict[str, DeviceProp]: + async def _async_update_data(self) -> DeviceProp: """Update data via library.""" try: - await asyncio.gather( - *( - self._update_device_prop(device_info) - for device_info in self.devices_info.values() - ) - ) + await self._update_device_prop() except RoborockException as ex: raise UpdateFailed(ex) from ex - return { - device_id: device_info.props - for device_id, device_info in self.devices_info.items() - } + return self.device_info.props diff --git a/homeassistant/components/roborock/device.py b/homeassistant/components/roborock/device.py index e544147e9b8e..39a9524226df 100644 --- a/homeassistant/components/roborock/device.py +++ b/homeassistant/components/roborock/device.py @@ -3,14 +3,15 @@ from typing import Any from roborock.containers import Status -from roborock.typing import RoborockCommand +from roborock.exceptions import RoborockException +from roborock.roborock_typing import RoborockCommand +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import RoborockDataUpdateCoordinator from .const import DOMAIN -from .models import RoborockHassDeviceInfo class RoborockCoordinatedEntity(CoordinatorEntity[RoborockDataUpdateCoordinator]): @@ -21,46 +22,43 @@ class RoborockCoordinatedEntity(CoordinatorEntity[RoborockDataUpdateCoordinator] def __init__( self, unique_id: str, - device_info: RoborockHassDeviceInfo, coordinator: RoborockDataUpdateCoordinator, ) -> None: """Initialize the coordinated Roborock Device.""" super().__init__(coordinator) self._attr_unique_id = unique_id - self._device_name = device_info.device.name - self._device_id = device_info.device.duid - self._device_model = device_info.product.model - self._fw_version = device_info.device.fv @property def _device_status(self) -> Status: """Return the status of the device.""" data = self.coordinator.data if data: - device_data = data.get(self._device_id) - if device_data: - status = device_data.status - if status: - return status + status = data.status + if status: + return status return Status({}) @property def device_info(self) -> DeviceInfo: """Return the device info.""" return DeviceInfo( - name=self._device_name, - identifiers={(DOMAIN, self._device_id)}, + name=self.coordinator.device_info.device.name, + identifiers={(DOMAIN, self.coordinator.device_info.device.duid)}, manufacturer="Roborock", - model=self._device_model, - sw_version=self._fw_version, + model=self.coordinator.device_info.product.model, + sw_version=self.coordinator.device_info.device.fv, ) async def send( self, command: RoborockCommand, params: dict[str, Any] | list[Any] | None = None ) -> dict: """Send a command to a vacuum cleaner.""" - response = await self.coordinator.api.send_command( - self._device_id, command, params - ) + try: + response = await self.coordinator.api.send_command(command, params) + except RoborockException as err: + raise HomeAssistantError( + f"Error while calling {command.name} with {params}" + ) from err + await self.coordinator.async_request_refresh() return response diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index 7cb686f18519..44a4cba89c98 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.8.3"] + "requirements": ["python-roborock==0.17.0"] } diff --git a/homeassistant/components/roborock/models.py b/homeassistant/components/roborock/models.py index 0377cebd425e..a30c84ce1da4 100644 --- a/homeassistant/components/roborock/models.py +++ b/homeassistant/components/roborock/models.py @@ -2,7 +2,7 @@ from dataclasses import dataclass from roborock.containers import HomeDataDevice, HomeDataProduct, NetworkInfo -from roborock.typing import DeviceProp +from roborock.roborock_typing import DeviceProp @dataclass diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index 646c49048543..d27888a17793 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -2,31 +2,32 @@ from collections.abc import Callable from dataclasses import dataclass -from roborock.code_mappings import RoborockMopIntensityCode, RoborockMopModeCode from roborock.containers import Status -from roborock.exceptions import RoborockException -from roborock.typing import RoborockCommand +from roborock.roborock_typing import RoborockCommand from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import slugify from .const import DOMAIN from .coordinator import RoborockDataUpdateCoordinator from .device import RoborockCoordinatedEntity -from .models import RoborockHassDeviceInfo @dataclass class RoborockSelectDescriptionMixin: """Define an entity description mixin for select entities.""" + # The command that the select entity will send to the api. api_command: RoborockCommand + # Gets the current value of the select entity. value_fn: Callable[[Status], str] - options_lambda: Callable[[str], list[int]] + # Gets all options of the select entity. + options_lambda: Callable[[Status], list[str]] + # Takes the value from the select entiy and converts it for the api. + parameter_lambda: Callable[[str, Status], list[int]] @dataclass @@ -40,22 +41,20 @@ SELECT_DESCRIPTIONS: list[RoborockSelectDescription] = [ RoborockSelectDescription( key="water_box_mode", translation_key="mop_intensity", - options=RoborockMopIntensityCode.values(), api_command=RoborockCommand.SET_WATER_BOX_CUSTOM_MODE, - value_fn=lambda data: data.water_box_mode, - options_lambda=lambda data: [ - k for k, v in RoborockMopIntensityCode.items() if v == data - ], + value_fn=lambda data: data.water_box_mode.name, + options_lambda=lambda data: data.water_box_mode.keys() + if data.water_box_mode + else None, + parameter_lambda=lambda key, status: [status.water_box_mode.as_dict().get(key)], ), RoborockSelectDescription( key="mop_mode", translation_key="mop_mode", - options=RoborockMopModeCode.values(), api_command=RoborockCommand.SET_MOP_MODE, - value_fn=lambda data: data.mop_mode, - options_lambda=lambda data: [ - k for k, v in RoborockMopModeCode.items() if v == data - ], + value_fn=lambda data: data.mop_mode.name, + 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)], ), ] @@ -67,18 +66,18 @@ async def async_setup_entry( ) -> None: """Set up Roborock select platform.""" - coordinator: RoborockDataUpdateCoordinator = hass.data[DOMAIN][ + coordinators: dict[str, RoborockDataUpdateCoordinator] = hass.data[DOMAIN][ config_entry.entry_id ] async_add_entities( RoborockSelectEntity( f"{description.key}_{slugify(device_id)}", - device_info, coordinator, description, ) - for device_id, device_info in coordinator.devices_info.items() + for device_id, coordinator in coordinators.items() for description in SELECT_DESCRIPTIONS + if description.options_lambda(coordinator.device_info.props.status) is not None ) @@ -90,25 +89,20 @@ class RoborockSelectEntity(RoborockCoordinatedEntity, SelectEntity): def __init__( self, unique_id: str, - device_info: RoborockHassDeviceInfo, coordinator: RoborockDataUpdateCoordinator, entity_description: RoborockSelectDescription, ) -> None: """Create a select entity.""" self.entity_description = entity_description - super().__init__(unique_id, device_info, coordinator) + super().__init__(unique_id, coordinator) + self._attr_options = self.entity_description.options_lambda(self._device_status) async def async_select_option(self, option: str) -> None: - """Set the mop intensity.""" - try: - await self.send( - self.entity_description.api_command, - self.entity_description.options_lambda(option), - ) - except RoborockException as err: - raise HomeAssistantError( - f"Error while setting {self.entity_description.key} to {option}" - ) from err + """Set the option.""" + await self.send( + self.entity_description.api_command, + self.entity_description.parameter_lambda(option, self._device_status), + ) @property def current_option(self) -> str | None: diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 6bd19787d203..64c9d268e56c 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -34,7 +34,8 @@ "standard": "Standard", "deep": "Deep", "deep_plus": "Deep+", - "custom": "Custom" + "custom": "Custom", + "fast": "Fast" } }, "mop_intensity": { diff --git a/homeassistant/components/roborock/vacuum.py b/homeassistant/components/roborock/vacuum.py index 4306afb25e41..666b8488d803 100644 --- a/homeassistant/components/roborock/vacuum.py +++ b/homeassistant/components/roborock/vacuum.py @@ -1,8 +1,8 @@ """Support for Roborock vacuum class.""" from typing import Any -from roborock.code_mappings import RoborockFanPowerCode, RoborockStateCode -from roborock.typing import RoborockCommand +from roborock.code_mappings import RoborockStateCode +from roborock.roborock_typing import RoborockCommand from homeassistant.components.vacuum import ( STATE_CLEANING, @@ -22,51 +22,46 @@ from homeassistant.util import slugify from .const import DOMAIN from .coordinator import RoborockDataUpdateCoordinator from .device import RoborockCoordinatedEntity -from .models import RoborockHassDeviceInfo STATE_CODE_TO_STATE = { - RoborockStateCode["1"]: STATE_IDLE, # "Starting" - RoborockStateCode["2"]: STATE_IDLE, # "Charger disconnected" - RoborockStateCode["3"]: STATE_IDLE, # "Idle" - RoborockStateCode["4"]: STATE_CLEANING, # "Remote control active" - RoborockStateCode["5"]: STATE_CLEANING, # "Cleaning" - RoborockStateCode["6"]: STATE_RETURNING, # "Returning home" - RoborockStateCode["7"]: STATE_CLEANING, # "Manual mode" - RoborockStateCode["8"]: STATE_DOCKED, # "Charging" - RoborockStateCode["9"]: STATE_ERROR, # "Charging problem" - RoborockStateCode["10"]: STATE_PAUSED, # "Paused" - RoborockStateCode["11"]: STATE_CLEANING, # "Spot cleaning" - RoborockStateCode["12"]: STATE_ERROR, # "Error" - RoborockStateCode["13"]: STATE_IDLE, # "Shutting down" - RoborockStateCode["14"]: STATE_DOCKED, # "Updating" - RoborockStateCode["15"]: STATE_RETURNING, # "Docking" - RoborockStateCode["16"]: STATE_CLEANING, # "Going to target" - RoborockStateCode["17"]: STATE_CLEANING, # "Zoned cleaning" - RoborockStateCode["18"]: STATE_CLEANING, # "Segment cleaning" - RoborockStateCode["22"]: STATE_DOCKED, # "Emptying the bin" on s7+ - RoborockStateCode["23"]: STATE_DOCKED, # "Washing the mop" on s7maxV - RoborockStateCode["26"]: STATE_RETURNING, # "Going to wash the mop" on s7maxV - RoborockStateCode["100"]: STATE_DOCKED, # "Charging complete" - RoborockStateCode["101"]: STATE_ERROR, # "Device offline" + RoborockStateCode.starting: STATE_IDLE, # "Starting" + RoborockStateCode.charger_disconnected: STATE_IDLE, # "Charger disconnected" + RoborockStateCode.idle: STATE_IDLE, # "Idle" + RoborockStateCode.remote_control_active: STATE_CLEANING, # "Remote control active" + RoborockStateCode.cleaning: STATE_CLEANING, # "Cleaning" + RoborockStateCode.returning_home: STATE_RETURNING, # "Returning home" + RoborockStateCode.manual_mode: STATE_CLEANING, # "Manual mode" + RoborockStateCode.charging: STATE_DOCKED, # "Charging" + RoborockStateCode.charging_problem: STATE_ERROR, # "Charging problem" + RoborockStateCode.paused: STATE_PAUSED, # "Paused" + RoborockStateCode.spot_cleaning: STATE_CLEANING, # "Spot cleaning" + RoborockStateCode.error: STATE_ERROR, # "Error" + RoborockStateCode.shutting_down: STATE_IDLE, # "Shutting down" + RoborockStateCode.updating: STATE_DOCKED, # "Updating" + RoborockStateCode.docking: STATE_RETURNING, # "Docking" + RoborockStateCode.going_to_target: STATE_CLEANING, # "Going to target" + RoborockStateCode.zoned_cleaning: STATE_CLEANING, # "Zoned cleaning" + RoborockStateCode.segment_cleaning: STATE_CLEANING, # "Segment cleaning" + RoborockStateCode.emptying_the_bin: STATE_DOCKED, # "Emptying the bin" on s7+ + RoborockStateCode.washing_the_mop: STATE_DOCKED, # "Washing the mop" on s7maxV + RoborockStateCode.going_to_wash_the_mop: STATE_RETURNING, # "Going to wash the mop" on s7maxV + RoborockStateCode.charging_complete: STATE_DOCKED, # "Charging complete" + RoborockStateCode.device_offline: STATE_ERROR, # "Device offline" } -ATTR_STATUS = "status" -ATTR_ERROR = "error" - - async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Roborock sensor.""" - coordinator: RoborockDataUpdateCoordinator = hass.data[DOMAIN][ + coordinators: dict[str, RoborockDataUpdateCoordinator] = hass.data[DOMAIN][ config_entry.entry_id ] async_add_entities( - RoborockVacuum(slugify(device_id), device_info, coordinator) - for device_id, device_info in coordinator.devices_info.items() + RoborockVacuum(slugify(device_id), coordinator) + for device_id, coordinator in coordinators.items() ) @@ -87,28 +82,22 @@ class RoborockVacuum(RoborockCoordinatedEntity, StateVacuumEntity): | VacuumEntityFeature.STATE | VacuumEntityFeature.START ) - _attr_fan_speed_list = RoborockFanPowerCode.values() def __init__( self, unique_id: str, - device: RoborockHassDeviceInfo, coordinator: RoborockDataUpdateCoordinator, ) -> None: """Initialize a vacuum.""" StateVacuumEntity.__init__(self) - RoborockCoordinatedEntity.__init__(self, unique_id, device, coordinator) + RoborockCoordinatedEntity.__init__(self, unique_id, coordinator) + self._attr_fan_speed_list = self._device_status.fan_power.keys() @property def state(self) -> str | None: """Return the status of the vacuum cleaner.""" return STATE_CODE_TO_STATE.get(self._device_status.state) - @property - def status(self) -> str | None: - """Return the status of the vacuum cleaner.""" - return self._device_status.status - @property def battery_level(self) -> int | None: """Return the battery level of the vacuum cleaner.""" @@ -117,12 +106,12 @@ class RoborockVacuum(RoborockCoordinatedEntity, StateVacuumEntity): @property def fan_speed(self) -> str | None: """Return the fan speed of the vacuum cleaner.""" - return self._device_status.fan_power + return self._device_status.fan_power.name @property - def error(self) -> str | None: - """Get the error str if an error code exists.""" - return self._device_status.error + def status(self) -> str | None: + """Return the status of the vacuum cleaner.""" + return self._device_status.state.name async def async_start(self) -> None: """Start the vacuum.""" @@ -152,11 +141,11 @@ class RoborockVacuum(RoborockCoordinatedEntity, StateVacuumEntity): """Set vacuum fan speed.""" await self.send( RoborockCommand.SET_CUSTOM_MODE, - [k for k, v in RoborockFanPowerCode.items() if v == fan_speed], + [self._device_status.fan_power.as_dict().get(fan_speed)], ) await self.coordinator.async_request_refresh() - async def async_start_pause(self): + async def async_start_pause(self) -> None: """Start, pause or resume the cleaning task.""" if self.state == STATE_CLEANING: await self.async_pause() diff --git a/requirements_all.txt b/requirements_all.txt index 93a22f3a149b..1edeab6218e5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2111,7 +2111,7 @@ python-qbittorrent==0.4.2 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==0.8.3 +python-roborock==0.17.0 # homeassistant.components.smarttub python-smarttub==0.0.33 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bcedff42ac1b..f2e3928e6644 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1531,7 +1531,7 @@ python-picnic-api==1.1.0 python-qbittorrent==0.4.2 # homeassistant.components.roborock -python-roborock==0.8.3 +python-roborock==0.17.0 # homeassistant.components.smarttub python-smarttub==0.0.33 diff --git a/tests/components/roborock/mock_data.py b/tests/components/roborock/mock_data.py index 55eb80868425..15e69cee9d9d 100644 --- a/tests/components/roborock/mock_data.py +++ b/tests/components/roborock/mock_data.py @@ -5,13 +5,13 @@ from roborock.containers import ( CleanRecord, CleanSummary, Consumable, - DNDTimer, + DnDTimer, HomeData, NetworkInfo, - Status, + S7Status, UserData, ) -from roborock.typing import DeviceProp +from roborock.roborock_typing import DeviceProp # All data is based on a U.S. customer with a Roborock S7 MaxV Ultra USER_EMAIL = "user@domain.com" @@ -311,7 +311,7 @@ CONSUMABLE = Consumable.from_dict( } ) -DND_TIMER = DNDTimer.from_dict( +DND_TIMER = DnDTimer.from_dict( { "start_hour": 22, "start_minute": 0, @@ -321,7 +321,7 @@ DND_TIMER = DNDTimer.from_dict( } ) -STATUS = Status.from_dict( +STATUS = S7Status.from_dict( { "msg_ver": 2, "msg_seq": 458, @@ -367,7 +367,6 @@ STATUS = Status.from_dict( "unsave_map_flag": 0, } ) - PROP = DeviceProp(STATUS, DND_TIMER, CLEAN_SUMMARY, CONSUMABLE, CLEAN_RECORD) NETWORK_INFO = NetworkInfo( diff --git a/tests/components/roborock/test_init.py b/tests/components/roborock/test_init.py index 18d9ee1bafeb..05bf08484758 100644 --- a/tests/components/roborock/test_init.py +++ b/tests/components/roborock/test_init.py @@ -1,8 +1,6 @@ """Test for Roborock init.""" from unittest.mock import patch -from roborock.exceptions import RoborockTimeout - from homeassistant.components.roborock.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -10,7 +8,6 @@ from homeassistant.helpers.update_coordinator import UpdateFailed from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry -from tests.components.roborock.mock_data import HOME_DATA, NETWORK_INFO async def test_unload_entry( @@ -41,23 +38,3 @@ async def test_config_entry_not_ready( ): await async_setup_component(hass, DOMAIN, {}) assert mock_roborock_entry.state is ConfigEntryState.SETUP_RETRY - - -async def test_continue_setup_mqtt_disconnect_fail( - hass: HomeAssistant, mock_roborock_entry: MockConfigEntry -): - """Test that if disconnect fails, we still continue setting up.""" - 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.RoborockMqttClient.async_disconnect", - side_effect=RoborockTimeout(), - ), patch( - "homeassistant.components.roborock.RoborockDataUpdateCoordinator.async_config_entry_first_refresh" - ): - await async_setup_component(hass, DOMAIN, {}) - assert mock_roborock_entry.state is ConfigEntryState.LOADED diff --git a/tests/components/roborock/test_vacuum.py b/tests/components/roborock/test_vacuum.py index f6cc5e81d1be..80fbd4092c05 100644 --- a/tests/components/roborock/test_vacuum.py +++ b/tests/components/roborock/test_vacuum.py @@ -5,7 +5,7 @@ from typing import Any from unittest.mock import patch import pytest -from roborock.typing import RoborockCommand +from roborock.roborock_typing import RoborockCommand from homeassistant.components.vacuum import ( SERVICE_CLEAN_SPOT, @@ -50,7 +50,7 @@ async def test_registry_entries( ( SERVICE_SET_FAN_SPEED, RoborockCommand.SET_CUSTOM_MODE, - {"fan_speed": "silent"}, + {"fan_speed": "quiet"}, [101], ), ( @@ -86,6 +86,5 @@ async def test_commands( blocking=True, ) assert mock_send_command.call_count == 1 - assert mock_send_command.call_args[0][0] == DEVICE_ID - assert mock_send_command.call_args[0][1] == command - assert mock_send_command.call_args[0][2] == called_params + assert mock_send_command.call_args[0][0] == command + assert mock_send_command.call_args[0][1] == called_params From 09a8479cf0a5a232e23da32f8ad24587321443b5 Mon Sep 17 00:00:00 2001 From: Tom Brien Date: Fri, 19 May 2023 22:31:19 +0100 Subject: [PATCH 406/822] Add MASK wallets to Coinbase (#90742) --- homeassistant/components/coinbase/const.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/coinbase/const.py b/homeassistant/components/coinbase/const.py index 13415147ef93..c5fdec4d511b 100644 --- a/homeassistant/components/coinbase/const.py +++ b/homeassistant/components/coinbase/const.py @@ -157,6 +157,7 @@ WALLETS = { "LYD": "LYD", "MAD": "MAD", "MANA": "MANA", + "MASK": "MASK", "MATIC": "MATIC", "MDL": "MDL", "MGA": "MGA", From 1935e126bfb8e3155954d66464bf76094da1c18f Mon Sep 17 00:00:00 2001 From: Jafar Atili Date: Sat, 20 May 2023 13:13:32 +0300 Subject: [PATCH 407/822] Add new integration to control Electra Smart HVAC devices (#70361) * Added new integration to support Electra Smart (HVAC) * fixes + option to set scan interval * renamed the module to electrasmart and added unittests * added non tested files to .coveragerc * changed the usage from UpdateCoordinator to each entity updates it self * small fixes * increased pypi package version, increased polling timeout to 60 seconds, improved error handling * PARALLEL_UPDATE=1 to prevent multi access to the API * code improvements * aligned with the new HA APIs * fixes * fixes * more * fixes * more * more * handled re-atuh flow * fixed test * removed hvac action * added shabat mode * tests: 100% coverage * ran hassfest * Update homeassistant/components/electrasmart/manifest.json Co-authored-by: Shay Levy * Update homeassistant/components/electrasmart/manifest.json Co-authored-by: Shay Levy * Update homeassistant/components/electrasmart/manifest.json Co-authored-by: Shay Levy * Update homeassistant/components/electrasmart/climate.py Co-authored-by: Shay Levy * address Shay's comments * address Shay's comments * address more comments --------- Co-authored-by: Shay Levy --- .coveragerc | 2 + .strict-typing | 1 + CODEOWNERS | 2 + .../components/electrasmart/__init__.py | 46 +++ .../components/electrasmart/climate.py | 334 ++++++++++++++++++ .../components/electrasmart/config_flow.py | 158 +++++++++ .../components/electrasmart/const.py | 13 + .../components/electrasmart/manifest.json | 9 + .../components/electrasmart/strings.json | 25 ++ .../electrasmart/translations/en.json | 25 ++ 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/electrasmart/__init__.py | 1 + .../fixtures/generate_token_response.json | 6 + .../fixtures/invalid_otp_response.json | 6 + .../invalid_phone_number_response.json | 6 + .../electrasmart/fixtures/otp_response.json | 11 + .../electrasmart/test_config_flow.py | 164 +++++++++ 21 files changed, 832 insertions(+) create mode 100644 homeassistant/components/electrasmart/__init__.py create mode 100644 homeassistant/components/electrasmart/climate.py create mode 100644 homeassistant/components/electrasmart/config_flow.py create mode 100644 homeassistant/components/electrasmart/const.py create mode 100644 homeassistant/components/electrasmart/manifest.json create mode 100644 homeassistant/components/electrasmart/strings.json create mode 100644 homeassistant/components/electrasmart/translations/en.json create mode 100644 tests/components/electrasmart/__init__.py create mode 100644 tests/components/electrasmart/fixtures/generate_token_response.json create mode 100644 tests/components/electrasmart/fixtures/invalid_otp_response.json create mode 100644 tests/components/electrasmart/fixtures/invalid_phone_number_response.json create mode 100644 tests/components/electrasmart/fixtures/otp_response.json create mode 100644 tests/components/electrasmart/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 834232c90258..6f3b1b092cfa 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1535,6 +1535,8 @@ omit = homeassistant/components/zwave_me/sensor.py homeassistant/components/zwave_me/siren.py homeassistant/components/zwave_me/switch.py + homeassistant/components/electrasmart/climate.py + homeassistant/components/electrasmart/__init__.py [report] # Regexes for lines to exclude from consideration diff --git a/.strict-typing b/.strict-typing index f7297d8e6808..fc40996a37bf 100644 --- a/.strict-typing +++ b/.strict-typing @@ -105,6 +105,7 @@ homeassistant.components.dormakaba_dkey.* homeassistant.components.dsmr.* homeassistant.components.dunehd.* homeassistant.components.efergy.* +homeassistant.components.electrasmart.* homeassistant.components.elgato.* homeassistant.components.elkm1.* homeassistant.components.emulated_hue.* diff --git a/CODEOWNERS b/CODEOWNERS index c2eba3864204..a870c7dea3cc 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -309,6 +309,8 @@ build.json @home-assistant/supervisor /homeassistant/components/egardia/ @jeroenterheerdt /homeassistant/components/eight_sleep/ @mezz64 @raman325 /tests/components/eight_sleep/ @mezz64 @raman325 +/homeassistant/components/electrasmart/ @jafar-atili +/tests/components/electrasmart/ @jafar-atili /homeassistant/components/elgato/ @frenck /tests/components/elgato/ @frenck /homeassistant/components/elkm1/ @gwww @bdraco diff --git a/homeassistant/components/electrasmart/__init__.py b/homeassistant/components/electrasmart/__init__.py new file mode 100644 index 000000000000..6fb9c35757f8 --- /dev/null +++ b/homeassistant/components/electrasmart/__init__.py @@ -0,0 +1,46 @@ +"""The Electra Air Conditioner integration.""" +from __future__ import annotations + +from typing import cast + +from electrasmart.api import ElectraAPI, ElectraApiError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_TOKEN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_IMEI, DOMAIN + +PLATFORMS: list[Platform] = [Platform.CLIMATE] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Electra Smart Air Conditioner from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + entry.async_on_unload(entry.add_update_listener(update_listener)) + hass.data[DOMAIN][entry.entry_id] = ElectraAPI( + async_get_clientsession(hass), entry.data[CONF_IMEI], entry.data[CONF_TOKEN] + ) + + try: + await cast(ElectraAPI, hass.data[DOMAIN][entry.entry_id]).fetch_devices() + except ElectraApiError as exp: + raise ConfigEntryNotReady(f"Error communicating with API: {exp}") from exp + + 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 + + +async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Update listener.""" + await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/electrasmart/climate.py b/homeassistant/components/electrasmart/climate.py new file mode 100644 index 000000000000..361f906133d4 --- /dev/null +++ b/homeassistant/components/electrasmart/climate.py @@ -0,0 +1,334 @@ +"""Support for the Electra climate.""" + +from __future__ import annotations + +from datetime import timedelta +import logging +import time +from typing import Any + +from electrasmart.api import STATUS_SUCCESS, Attributes, ElectraAPI, ElectraApiError +from electrasmart.device import ElectraAirConditioner, OperationMode +from electrasmart.device.const import MAX_TEMP, MIN_TEMP, Feature + +from homeassistant.components.climate import ( + FAN_AUTO, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + SWING_BOTH, + SWING_HORIZONTAL, + SWING_OFF, + SWING_VERTICAL, + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +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.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ( + API_DELAY, + CONSECUTIVE_FAILURE_THRESHOLD, + DOMAIN, + PRESET_NONE, + PRESET_SHABAT, + SCAN_INTERVAL_SEC, + UNAVAILABLE_THRESH_SEC, +) + +FAN_ELECTRA_TO_HASS = { + OperationMode.FAN_SPEED_AUTO: FAN_AUTO, + OperationMode.FAN_SPEED_LOW: FAN_LOW, + OperationMode.FAN_SPEED_MED: FAN_MEDIUM, + OperationMode.FAN_SPEED_HIGH: FAN_HIGH, +} + +FAN_HASS_TO_ELECTRA = { + FAN_AUTO: OperationMode.FAN_SPEED_AUTO, + FAN_LOW: OperationMode.FAN_SPEED_LOW, + FAN_MEDIUM: OperationMode.FAN_SPEED_MED, + FAN_HIGH: OperationMode.FAN_SPEED_HIGH, +} + +HVAC_MODE_ELECTRA_TO_HASS = { + OperationMode.MODE_COOL: HVACMode.COOL, + OperationMode.MODE_HEAT: HVACMode.HEAT, + OperationMode.MODE_FAN: HVACMode.FAN_ONLY, + OperationMode.MODE_DRY: HVACMode.DRY, + OperationMode.MODE_AUTO: HVACMode.AUTO, +} + +HVAC_MODE_HASS_TO_ELECTRA = { + HVACMode.COOL: OperationMode.MODE_COOL, + HVACMode.HEAT: OperationMode.MODE_HEAT, + HVACMode.FAN_ONLY: OperationMode.MODE_FAN, + HVACMode.DRY: OperationMode.MODE_DRY, + HVACMode.AUTO: OperationMode.MODE_AUTO, +} + +ELECTRA_FAN_MODES = [FAN_AUTO, FAN_HIGH, FAN_MEDIUM, FAN_LOW] +ELECTRA_MODES = [ + HVACMode.OFF, + HVACMode.HEAT, + HVACMode.COOL, + HVACMode.DRY, + HVACMode.FAN_ONLY, + HVACMode.AUTO, +] + +_LOGGER = logging.getLogger(__name__) + + +SCAN_INTERVAL = timedelta(seconds=SCAN_INTERVAL_SEC) +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Add Electra AC devices.""" + api: ElectraAPI = hass.data[DOMAIN][entry.entry_id] + + _LOGGER.debug("Discovered %i Electra devices", len(api.devices)) + async_add_entities( + (ElectraClimateEntity(device, api) for device in api.devices), True + ) + + +class ElectraClimateEntity(ClimateEntity): + """Define an Electra climate.""" + + _attr_fan_modes = ELECTRA_FAN_MODES + _attr_target_temperature_step = 1 + _attr_max_temp = MAX_TEMP + _attr_min_temp = MIN_TEMP + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_hvac_modes = ELECTRA_MODES + + def __init__(self, device: ElectraAirConditioner, api: ElectraAPI) -> None: + """Initialize Electra climate entity.""" + self._api = api + self._electra_ac_device = device + self._attr_name = device.name + self._attr_unique_id = device.mac + self._attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ) + + swing_modes: list = [] + if Feature.V_SWING in self._electra_ac_device.features: + swing_modes.append(SWING_VERTICAL) + if Feature.H_SWING in self._electra_ac_device.features: + swing_modes.append(SWING_HORIZONTAL) + + if all(elem in [SWING_HORIZONTAL, SWING_VERTICAL] for elem in swing_modes): + swing_modes.append(SWING_BOTH) + if swing_modes: + swing_modes.append(SWING_OFF) + self._attr_swing_modes = swing_modes + self._attr_supported_features |= ClimateEntityFeature.SWING_MODE + + self._attr_preset_modes = [ + PRESET_NONE, + PRESET_SHABAT, + ] + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._electra_ac_device.mac)}, + name=self.name, + model=self._electra_ac_device.model, + manufacturer=self._electra_ac_device.manufactor, + ) + + # This attribute will be used to mark the time we communicated + # a command to the API + self._last_state_update = 0 + + # count the consecutive update failures, used to print error log + self._consecutive_failures = 0 + self._skip_update = True + self._was_available = True + + _LOGGER.debug("Added %s Electra AC device", self._attr_name) + + @property + def available(self) -> bool: + """Return True if the AC is available.""" + return ( + not self._electra_ac_device.is_disconnected(UNAVAILABLE_THRESH_SEC) + and super().available + ) + + async def async_update(self) -> None: + """Update Electra device.""" + + # if we communicated a change to the API in the last API_DELAY seconds, + # then don't receive any updates as the API takes few seconds + # until it start sending it last recent change + if self._last_state_update and int(time.time()) < ( + self._last_state_update + API_DELAY + ): + _LOGGER.debug("Skipping state update, keeping old values") + return + + self._last_state_update = 0 + + try: + # skip the first update only as we already got the devices with their current state + if self._skip_update: + self._skip_update = False + else: + await self._api.get_last_telemtry(self._electra_ac_device) + + if not self.available: + # show the warning once upon state change + if self._was_available: + _LOGGER.warning( + "Electra AC %s (%s) is not available, check its status in the Electra Smart mobile app", + self.name, + self._electra_ac_device.mac, + ) + self._was_available = False + return + + if not self._was_available: + _LOGGER.info( + "%s (%s) is now available", + self._electra_ac_device.mac, + self.name, + ) + self._was_available = True + + _LOGGER.debug( + "%s (%s) state updated: %s", + self._electra_ac_device.mac, + self.name, + self._electra_ac_device.__dict__, + ) + except ElectraApiError as exp: + self._consecutive_failures += 1 + _LOGGER.warning( + "Failed to get %s state: %s (try #%i since last success), keeping old state", + self.name, + exp, + self._consecutive_failures, + ) + + if self._consecutive_failures >= CONSECUTIVE_FAILURE_THRESHOLD: + raise HomeAssistantError( + f"Failed to get {self.name} state: {exp} for the {self._consecutive_failures} time", + ) from ElectraApiError + + self._consecutive_failures = 0 + self._update_device_attrs() + + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set AC fan mode.""" + mode = FAN_HASS_TO_ELECTRA[fan_mode] + self._electra_ac_device.set_fan_speed(mode) + await self._async_operate_electra_ac() + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set hvac mode.""" + + if hvac_mode == HVACMode.OFF: + self._electra_ac_device.turn_off() + else: + self._electra_ac_device.set_mode(HVAC_MODE_HASS_TO_ELECTRA[hvac_mode]) + self._electra_ac_device.turn_on() + + await self._async_operate_electra_ac() + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + + if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: + raise ValueError("No target temperature provided") + + self._electra_ac_device.set_temperature(temperature) + await self._async_operate_electra_ac() + + def _update_device_attrs(self) -> None: + self._attr_fan_mode = FAN_ELECTRA_TO_HASS[ + self._electra_ac_device.get_fan_speed() + ] + self._attr_current_temperature = ( + self._electra_ac_device.get_sensor_temperature() + ) + self._attr_target_temperature = self._electra_ac_device.get_temperature() + + self._attr_hvac_mode = ( + HVACMode.OFF + if not self._electra_ac_device.is_on() + else HVAC_MODE_ELECTRA_TO_HASS[self._electra_ac_device.get_mode()] + ) + + if ( + self._electra_ac_device.is_horizontal_swing() + and self._electra_ac_device.is_vertical_swing() + ): + self._attr_swing_mode = SWING_BOTH + elif self._electra_ac_device.is_horizontal_swing(): + self._attr_swing_mode = SWING_HORIZONTAL + elif self._electra_ac_device.is_vertical_swing(): + self._attr_swing_mode = SWING_VERTICAL + else: + self._attr_swing_mode = SWING_OFF + + self._attr_preset_mode = ( + PRESET_SHABAT if self._electra_ac_device.get_shabat_mode() else PRESET_NONE + ) + + async def async_set_swing_mode(self, swing_mode: str) -> None: + """Set AC swing mdde.""" + if swing_mode == SWING_BOTH: + self._electra_ac_device.set_horizontal_swing(True) + self._electra_ac_device.set_vertical_swing(True) + + elif swing_mode == SWING_VERTICAL: + self._electra_ac_device.set_horizontal_swing(False) + self._electra_ac_device.set_vertical_swing(True) + + elif swing_mode == SWING_HORIZONTAL: + self._electra_ac_device.set_horizontal_swing(True) + self._electra_ac_device.set_vertical_swing(False) + else: + self._electra_ac_device.set_horizontal_swing(False) + self._electra_ac_device.set_vertical_swing(False) + + await self._async_operate_electra_ac() + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set Preset mode.""" + if preset_mode == PRESET_SHABAT: + self._electra_ac_device.set_shabat_mode(True) + else: + self._electra_ac_device.set_shabat_mode(False) + + await self._async_operate_electra_ac() + + async def _async_operate_electra_ac(self) -> None: + """Send HVAC parameters to API.""" + + try: + resp = await self._api.set_state(self._electra_ac_device) + except ElectraApiError as exp: + raise HomeAssistantError( + f"Error communicating with Electra API: {exp}" + ) from exp + + if not ( + resp[Attributes.STATUS] == STATUS_SUCCESS + and resp[Attributes.DATA][Attributes.RES] == STATUS_SUCCESS + ): + self._async_write_ha_state() + raise HomeAssistantError(f"Failed to update {self.name}, error: {resp}") + + self._update_device_attrs() + self._last_state_update = int(time.time()) + self._async_write_ha_state() diff --git a/homeassistant/components/electrasmart/config_flow.py b/homeassistant/components/electrasmart/config_flow.py new file mode 100644 index 000000000000..946a9f2854d0 --- /dev/null +++ b/homeassistant/components/electrasmart/config_flow.py @@ -0,0 +1,158 @@ +"""Config flow for Electra Air Conditioner integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from electrasmart.api import STATUS_SUCCESS, Attributes, ElectraAPI, ElectraApiError +from electrasmart.api.utils import generate_imei +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_TOKEN +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_IMEI, CONF_OTP, CONF_PHONE_NUMBER, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Electra Air Conditioner.""" + + VERSION = 1 + + def __init__(self) -> None: + """Device settings.""" + self._phone_number: str | None = None + self._description_placeholders = None + self._otp: str | None = None + self._imei: str | None = None + self._token: str | None = None + self._api: ElectraAPI | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + + if not self._api: + self._api = ElectraAPI(async_get_clientsession(self.hass)) + + errors: dict[str, Any] = {} + + if user_input is None: + return self._show_setup_form(user_input, errors, "user") + + return await self._validate_phone_number(user_input) + + def _show_setup_form( + self, + user_input: dict[str, str] | None = None, + errors: dict[str, str] | None = None, + step_id: str = "user", + ) -> FlowResult: + """Show the setup form to the user.""" + if user_input is None: + user_input = {} + + if step_id == "user": + schema = { + vol.Required( + CONF_PHONE_NUMBER, default=user_input.get(CONF_PHONE_NUMBER, "") + ): str + } + else: + schema = {vol.Required(CONF_OTP, default=user_input.get(CONF_OTP, "")): str} + + return self.async_show_form( + step_id=step_id, + data_schema=vol.Schema(schema), + errors=errors or {}, + description_placeholders=self._description_placeholders, + ) + + async def _validate_phone_number(self, user_input: dict[str, str]) -> FlowResult: + """Check if config is valid and create entry if so.""" + + self._phone_number = user_input[CONF_PHONE_NUMBER] + self._imei = generate_imei() + + # Check if already configured + if self.unique_id is None: + await self.async_set_unique_id(self._phone_number) + self._abort_if_unique_id_configured() + + assert isinstance(self._api, ElectraAPI) + + try: + resp = await self._api.generate_new_token(self._phone_number, self._imei) + except ElectraApiError as exp: + _LOGGER.error("Failed to connect to API: %s", exp) + return self._show_setup_form(user_input, {"base": "cannot_connect"}, "user") + + if resp[Attributes.STATUS] == STATUS_SUCCESS: + if resp[Attributes.DATA][Attributes.RES] != STATUS_SUCCESS: + return self._show_setup_form( + user_input, {CONF_PHONE_NUMBER: "invalid_phone_number"}, "user" + ) + + return await self.async_step_one_time_password() + + async def _validate_one_time_password( + self, user_input: dict[str, str] + ) -> FlowResult: + self._otp = user_input[CONF_OTP] + + assert isinstance(self._api, ElectraAPI) + assert isinstance(self._imei, str) + assert isinstance(self._phone_number, str) + assert isinstance(self._otp, str) + + try: + resp = await self._api.validate_one_time_password( + self._otp, self._imei, self._phone_number + ) + except ElectraApiError as exp: + _LOGGER.error("Failed to connect to API: %s", exp) + return self._show_setup_form( + user_input, {"base": "cannot_connect"}, CONF_OTP + ) + + if resp[Attributes.DATA][Attributes.RES] == STATUS_SUCCESS: + self._token = resp[Attributes.DATA][Attributes.TOKEN] + + data = { + CONF_TOKEN: self._token, + CONF_IMEI: self._imei, + CONF_PHONE_NUMBER: self._phone_number, + } + return self.async_create_entry(title=self._phone_number, data=data) + return self._show_setup_form(user_input, {CONF_OTP: "invalid_auth"}, CONF_OTP) + + async def async_step_one_time_password( + self, + user_input: dict[str, Any] | None = None, + errors: dict[str, str] | None = None, + ) -> FlowResult: + """Ask the verification code to the user.""" + if errors is None: + errors = {} + + if user_input is None: + return await self._show_otp_form(errors) + + return await self._validate_one_time_password(user_input) + + async def _show_otp_form( + self, + errors: dict[str, str] | None = None, + ) -> FlowResult: + """Show the verification_code form to the user.""" + + return self.async_show_form( + step_id=CONF_OTP, + data_schema=vol.Schema({vol.Required(CONF_OTP): str}), + errors=errors or {}, + ) diff --git a/homeassistant/components/electrasmart/const.py b/homeassistant/components/electrasmart/const.py new file mode 100644 index 000000000000..1a48dd3c463d --- /dev/null +++ b/homeassistant/components/electrasmart/const.py @@ -0,0 +1,13 @@ +"""Constants for the Electra Air Conditioner integration.""" + +DOMAIN = "electrasmart" + +CONF_PHONE_NUMBER = "phone_number" +CONF_OTP = "one_time_password" +CONF_IMEI = "imei" +SCAN_INTERVAL_SEC = 30 +API_DELAY = 5 +CONSECUTIVE_FAILURE_THRESHOLD = 4 +UNAVAILABLE_THRESH_SEC = 120 +PRESET_NONE = "None" +PRESET_SHABAT = "Shabat" diff --git a/homeassistant/components/electrasmart/manifest.json b/homeassistant/components/electrasmart/manifest.json new file mode 100644 index 000000000000..a2a3f928eeb4 --- /dev/null +++ b/homeassistant/components/electrasmart/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "electrasmart", + "name": "Electra Smart", + "codeowners": ["@jafar-atili"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/electrasmart", + "iot_class": "cloud_polling", + "requirements": ["pyelectra==1.2.0"] +} diff --git a/homeassistant/components/electrasmart/strings.json b/homeassistant/components/electrasmart/strings.json new file mode 100644 index 000000000000..06c7dfd6beda --- /dev/null +++ b/homeassistant/components/electrasmart/strings.json @@ -0,0 +1,25 @@ +{ + "config": { + "step": { + "user": { + "data": { + "phone_number": "Phone Number" + } + }, + "one_time_password": { + "data": { + "one_time_password": "One Time 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%]", + "invalid_phone_number": "Either wrong phone number or unregistered user" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/electrasmart/translations/en.json b/homeassistant/components/electrasmart/translations/en.json new file mode 100644 index 000000000000..c6afd6245402 --- /dev/null +++ b/homeassistant/components/electrasmart/translations/en.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "Phone number already configured" + }, + "error": { + "cannot_connect": "Failed to connect to Electra API", + "invalid_auth": "Wrong one time password key", + "invalid_phone_number": "Either wrong phone number or unregistered user", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "phone_number": "Phone Number (eg. 0501234567)" + } + }, + "one_time_password": { + "data": { + "one_time_password": "One Time Password (OTP)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 48c4051bb841..1b76aa2b56d4 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -113,6 +113,7 @@ FLOWS = { "edl21", "efergy", "eight_sleep", + "electrasmart", "elgato", "elkm1", "elmax", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index b3dc06926cad..c69ac70e7d67 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1299,6 +1299,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "electrasmart": { + "name": "Electra Smart", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "elgato": { "name": "Elgato", "integrations": { diff --git a/mypy.ini b/mypy.ini index b4155232ff12..fd5e87bd499e 100644 --- a/mypy.ini +++ b/mypy.ini @@ -812,6 +812,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.electrasmart.*] +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.elgato.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 1edeab6218e5..4e4e7755a75e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1607,6 +1607,9 @@ pyefergy==22.1.1 # homeassistant.components.eight_sleep pyeight==0.3.2 +# homeassistant.components.electrasmart +pyelectra==1.2.0 + # homeassistant.components.emby pyemby==1.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f2e3928e6644..8b64595dbeec 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1177,6 +1177,9 @@ pyefergy==22.1.1 # homeassistant.components.eight_sleep pyeight==0.3.2 +# homeassistant.components.electrasmart +pyelectra==1.2.0 + # homeassistant.components.everlights pyeverlights==0.1.0 diff --git a/tests/components/electrasmart/__init__.py b/tests/components/electrasmart/__init__.py new file mode 100644 index 000000000000..cb2db8dcdedd --- /dev/null +++ b/tests/components/electrasmart/__init__.py @@ -0,0 +1 @@ +"""Tests for the Electra Air Conditioner integration.""" diff --git a/tests/components/electrasmart/fixtures/generate_token_response.json b/tests/components/electrasmart/fixtures/generate_token_response.json new file mode 100644 index 000000000000..43de435e5640 --- /dev/null +++ b/tests/components/electrasmart/fixtures/generate_token_response.json @@ -0,0 +1,6 @@ +{ + "id": 99, + "status": 0, + "desc": "None", + "data": { "res": 0, "res_desc": "None" } +} diff --git a/tests/components/electrasmart/fixtures/invalid_otp_response.json b/tests/components/electrasmart/fixtures/invalid_otp_response.json new file mode 100644 index 000000000000..2df84b0703b8 --- /dev/null +++ b/tests/components/electrasmart/fixtures/invalid_otp_response.json @@ -0,0 +1,6 @@ +{ + "id": 99, + "status": 1, + "desc": "None", + "data": { "res": 100, "res_desc": "None" } +} diff --git a/tests/components/electrasmart/fixtures/invalid_phone_number_response.json b/tests/components/electrasmart/fixtures/invalid_phone_number_response.json new file mode 100644 index 000000000000..9ff9f3953568 --- /dev/null +++ b/tests/components/electrasmart/fixtures/invalid_phone_number_response.json @@ -0,0 +1,6 @@ +{ + "id": 99, + "status": 0, + "desc": "None", + "data": { "res": 100, "res_desc": "None" } +} diff --git a/tests/components/electrasmart/fixtures/otp_response.json b/tests/components/electrasmart/fixtures/otp_response.json new file mode 100644 index 000000000000..0e9623b616a8 --- /dev/null +++ b/tests/components/electrasmart/fixtures/otp_response.json @@ -0,0 +1,11 @@ +{ + "id": 99, + "status": 0, + "desc": "None", + "data": { + "token": "ec7a0db6c1f148ca8c0f48aabb5f8150", + "sid": "bd6f11f947244e5d9612eba89e91112b", + "res": 0, + "res_desc": "None" + } +} diff --git a/tests/components/electrasmart/test_config_flow.py b/tests/components/electrasmart/test_config_flow.py new file mode 100644 index 000000000000..f53bea3e96c7 --- /dev/null +++ b/tests/components/electrasmart/test_config_flow.py @@ -0,0 +1,164 @@ +"""Test the Electra Smart config flow.""" +from json import loads +from unittest.mock import patch + +from homeassistant import config_entries +from homeassistant.components.electrasmart.config_flow import ElectraApiError +from homeassistant.components.electrasmart.const import ( + CONF_OTP, + CONF_PHONE_NUMBER, + DOMAIN, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import load_fixture + + +async def test_form(hass: HomeAssistant): + """Test user config.""" + + mock_generate_token = loads(load_fixture("generate_token_response.json", DOMAIN)) + with patch( + "electrasmart.api.ElectraAPI.generate_new_token", + return_value=mock_generate_token, + ): + # test with required + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=None, + ) + + assert result["step_id"] == "user" + + # test with required + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_PHONE_NUMBER: "0521234567"}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == CONF_OTP + + +async def test_one_time_password(hass: HomeAssistant): + """Test one time password.""" + + mock_generate_token = loads(load_fixture("generate_token_response.json", DOMAIN)) + mock_otp_response = loads(load_fixture("otp_response.json", DOMAIN)) + with patch( + "electrasmart.api.ElectraAPI.generate_new_token", + return_value=mock_generate_token, + ), patch( + "electrasmart.api.ElectraAPI.validate_one_time_password", + return_value=mock_otp_response, + ), patch( + "electrasmart.api.ElectraAPI.fetch_devices", return_value=[] + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_PHONE_NUMBER: "0521234567", CONF_OTP: "1234"}, + ) + + # test with required + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_OTP: "1234"} + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + + +async def test_one_time_password_api_error(hass: HomeAssistant): + """Test one time password.""" + mock_generate_token = loads(load_fixture("generate_token_response.json", DOMAIN)) + with patch( + "electrasmart.api.ElectraAPI.generate_new_token", + return_value=mock_generate_token, + ), patch( + "electrasmart.api.ElectraAPI.validate_one_time_password", + side_effect=ElectraApiError, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_PHONE_NUMBER: "0521234567"}, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_OTP: "1234"} + ) + + assert result["type"] == FlowResultType.FORM + + +async def test_cannot_connect(hass: HomeAssistant): + """Test cannot connect.""" + + with patch( + "electrasmart.api.ElectraAPI.generate_new_token", + side_effect=ElectraApiError, + ): + # test with required + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_PHONE_NUMBER: "0521234567"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_invalid_phone_number(hass: HomeAssistant): + """Test invalid phone number.""" + + mock_invalid_phone_number_response = loads( + load_fixture("invalid_phone_number_response.json", DOMAIN) + ) + + with patch( + "electrasmart.api.ElectraAPI.generate_new_token", + return_value=mock_invalid_phone_number_response, + ): + # test with required + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_PHONE_NUMBER: "0521234567"}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"phone_number": "invalid_phone_number"} + + +async def test_invalid_auth(hass: HomeAssistant): + """Test invalid auth.""" + + mock_generate_token_response = loads( + load_fixture("generate_token_response.json", DOMAIN) + ) + mock_invalid_otp_response = loads(load_fixture("invalid_otp_response.json", DOMAIN)) + + with patch( + "electrasmart.api.ElectraAPI.generate_new_token", + return_value=mock_generate_token_response, + ), patch( + "electrasmart.api.ElectraAPI.validate_one_time_password", + return_value=mock_invalid_otp_response, + ): + # test with required + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_PHONE_NUMBER: "0521234567", CONF_OTP: "1234"}, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_OTP: "1234"} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == CONF_OTP + assert result["errors"] == {CONF_OTP: "invalid_auth"} From fa415480d62ca97bfff18b6ab9a6e13bc75f7231 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Sat, 20 May 2023 13:52:43 +0200 Subject: [PATCH 408/822] Bump pyoverkiz to 1.7.9 (#93275) Update PyOverkiz to v1.7.9 --- 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 dfd4a6c28e14..2d81b7bab071 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.7.8"], + "requirements": ["pyoverkiz==1.7.9"], "zeroconf": [ { "type": "_kizbox._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 4e4e7755a75e..ea1c2bc9aa42 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1868,7 +1868,7 @@ pyotgw==2.1.3 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.7.8 +pyoverkiz==1.7.9 # homeassistant.components.openweathermap pyowm==3.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8b64595dbeec..ba3daab6aa3e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1375,7 +1375,7 @@ pyotgw==2.1.3 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.7.8 +pyoverkiz==1.7.9 # homeassistant.components.openweathermap pyowm==3.2.0 From 2a2b19ed7c7840ce54823084758704bb74f05f5a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 20 May 2023 09:42:19 -0500 Subject: [PATCH 409/822] Add support for Yale Home brand to august (#93214) --- homeassistant/components/august/__init__.py | 30 ++-- .../components/august/binary_sensor.py | 4 + .../components/august/config_flow.py | 138 ++++++++++++++---- homeassistant/components/august/const.py | 2 + .../components/august/diagnostics.py | 5 +- homeassistant/components/august/gateway.py | 55 ++++--- homeassistant/components/august/lock.py | 2 + homeassistant/components/august/manifest.json | 2 +- homeassistant/components/august/strings.json | 15 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/august/test_config_flow.py | 90 +++++++++--- tests/components/august/test_diagnostics.py | 1 + tests/components/august/test_init.py | 38 ++++- 14 files changed, 296 insertions(+), 90 deletions(-) diff --git a/homeassistant/components/august/__init__.py b/homeassistant/components/august/__init__.py index 7839d8799018..f4e048ecf16c 100644 --- a/homeassistant/components/august/__init__.py +++ b/homeassistant/components/august/__init__.py @@ -7,6 +7,7 @@ from itertools import chain import logging from aiohttp import ClientError, ClientResponseError +from yalexs.const import DEFAULT_BRAND from yalexs.doorbell import Doorbell, DoorbellDetail from yalexs.exceptions import AugustApiAIOHTTPError from yalexs.lock import Lock, LockDetail @@ -16,7 +17,7 @@ from yalexs_ble import YaleXSBLEDiscovery from homeassistant.config_entries import SOURCE_INTEGRATION_DISCOVERY, ConfigEntry from homeassistant.const import CONF_PASSWORD -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import ( ConfigEntryAuthFailed, ConfigEntryNotReady, @@ -25,7 +26,7 @@ from homeassistant.exceptions import ( from homeassistant.helpers import device_registry as dr, discovery_flow from .activity import ActivityStream -from .const import DOMAIN, MIN_TIME_BETWEEN_DETAIL_UPDATES, PLATFORMS +from .const import CONF_BRAND, DOMAIN, MIN_TIME_BETWEEN_DETAIL_UPDATES, PLATFORMS from .exceptions import CannotConnect, InvalidAuth, RequireValidation from .gateway import AugustGateway from .subscriber import AugustSubscriberMixin @@ -122,19 +123,24 @@ def _async_trigger_ble_lock_discovery( class AugustData(AugustSubscriberMixin): """August data object.""" - def __init__(self, hass, config_entry, august_gateway): + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + august_gateway: AugustGateway, + ) -> None: """Init August data object.""" super().__init__(hass, MIN_TIME_BETWEEN_DETAIL_UPDATES) self._config_entry = config_entry self._hass = hass self._august_gateway = august_gateway - self.activity_stream = None + self.activity_stream: ActivityStream | None = None self._api = august_gateway.api - self._device_detail_by_id = {} - self._doorbells_by_id = {} - self._locks_by_id = {} - self._house_ids = set() - self._pubnub_unsub = None + self._device_detail_by_id: dict[str, LockDetail | DoorbellDetail] = {} + self._doorbells_by_id: dict[str, Doorbell] = {} + self._locks_by_id: dict[str, Lock] = {} + self._house_ids: set[str] = set() + self._pubnub_unsub: CALLBACK_TYPE | None = None async def async_setup(self): """Async setup of august device data and activities.""" @@ -185,7 +191,11 @@ class AugustData(AugustSubscriberMixin): ) await self.activity_stream.async_setup() pubnub.subscribe(self.async_pubnub_message) - self._pubnub_unsub = async_create_pubnub(user_data["UserID"], pubnub) + self._pubnub_unsub = async_create_pubnub( + user_data["UserID"], + pubnub, + self._config_entry.data.get(CONF_BRAND, DEFAULT_BRAND), + ) if self._locks_by_id: # Do not prevent setup as the sync can timeout diff --git a/homeassistant/components/august/binary_sensor.py b/homeassistant/components/august/binary_sensor.py index 1ab2369934c1..d380ee118340 100644 --- a/homeassistant/components/august/binary_sensor.py +++ b/homeassistant/components/august/binary_sensor.py @@ -50,6 +50,7 @@ def _retrieve_online_state(data: AugustData, detail: DoorbellDetail) -> bool: def _retrieve_motion_state(data: AugustData, detail: DoorbellDetail) -> bool: + assert data.activity_stream is not None latest = data.activity_stream.get_latest_device_activity( detail.device_id, {ActivityType.DOORBELL_MOTION} ) @@ -61,6 +62,7 @@ def _retrieve_motion_state(data: AugustData, detail: DoorbellDetail) -> bool: def _retrieve_image_capture_state(data: AugustData, detail: DoorbellDetail) -> bool: + assert data.activity_stream is not None latest = data.activity_stream.get_latest_device_activity( detail.device_id, {ActivityType.DOORBELL_IMAGE_CAPTURE} ) @@ -72,6 +74,7 @@ def _retrieve_image_capture_state(data: AugustData, detail: DoorbellDetail) -> b def _retrieve_ding_state(data: AugustData, detail: DoorbellDetail) -> bool: + assert data.activity_stream is not None latest = data.activity_stream.get_latest_device_activity( detail.device_id, {ActivityType.DOORBELL_DING} ) @@ -211,6 +214,7 @@ class AugustDoorBinarySensor(AugustEntityMixin, BinarySensorEntity): @callback def _update_from_data(self): """Get the latest state of the sensor and update activity.""" + assert self._data.activity_stream is not None door_activity = self._data.activity_stream.get_latest_device_activity( self._device_id, {ActivityType.DOOR_OPERATION} ) diff --git a/homeassistant/components/august/config_flow.py b/homeassistant/components/august/config_flow.py index 067f986c4e6b..58f1c2fc9764 100644 --- a/homeassistant/components/august/config_flow.py +++ b/homeassistant/components/august/config_flow.py @@ -1,33 +1,45 @@ """Config flow for August integration.""" from collections.abc import Mapping +from dataclasses import dataclass import logging from typing import Any import voluptuous as vol from yalexs.authenticator import ValidationResult +from yalexs.const import BRANDS, DEFAULT_BRAND from homeassistant import config_entries from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.data_entry_flow import FlowResult -from .const import CONF_LOGIN_METHOD, DOMAIN, LOGIN_METHODS, VERIFICATION_CODE_KEY +from .const import ( + CONF_ACCESS_TOKEN_CACHE_FILE, + CONF_BRAND, + CONF_LOGIN_METHOD, + DEFAULT_LOGIN_METHOD, + DOMAIN, + LOGIN_METHODS, + VERIFICATION_CODE_KEY, +) from .exceptions import CannotConnect, InvalidAuth, RequireValidation from .gateway import AugustGateway _LOGGER = logging.getLogger(__name__) -async def async_validate_input(data, august_gateway): +async def async_validate_input( + data: dict[str, Any], august_gateway: AugustGateway +) -> dict[str, Any]: """Validate the user input allows us to connect. Data has the keys from DATA_SCHEMA with values provided by the user. Request configuration steps from the user. """ + assert august_gateway.authenticator is not None + authenticator = august_gateway.authenticator if (code := data.get(VERIFICATION_CODE_KEY)) is not None: - result = await august_gateway.authenticator.async_validate_verification_code( - code - ) + result = await authenticator.async_validate_verification_code(code) _LOGGER.debug("Verification code validation: %s", result) if result != ValidationResult.VALIDATED: raise RequireValidation @@ -50,6 +62,16 @@ async def async_validate_input(data, august_gateway): } +@dataclass +class ValidateResult: + """Result from validation.""" + + validation_required: bool + info: dict[str, Any] + errors: dict[str, str] + description_placeholders: dict[str, str] + + class AugustConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for August.""" @@ -57,9 +79,9 @@ class AugustConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self): """Store an AugustGateway().""" - self._august_gateway = None - self._user_auth_details = {} - self._needs_reset = False + self._august_gateway: AugustGateway | None = None + self._user_auth_details: dict[str, Any] = {} + self._needs_reset = True self._mode = None super().__init__() @@ -70,19 +92,30 @@ class AugustConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_user_validate(self, user_input=None): """Handle authentication.""" - errors = {} + errors: dict[str, str] = {} + description_placeholders: dict[str, str] = {} if user_input is not None: - result = await self._async_auth_or_validate(user_input, errors) - if result is not None: - return result + self._user_auth_details.update(user_input) + validate_result = await self._async_auth_or_validate() + description_placeholders = validate_result.description_placeholders + if validate_result.validation_required: + return await self.async_step_validation() + if not (errors := validate_result.errors): + return await self._async_update_or_create_entry(validate_result.info) return self.async_show_form( step_id="user_validate", data_schema=vol.Schema( { + vol.Required( + CONF_BRAND, + default=self._user_auth_details.get(CONF_BRAND, DEFAULT_BRAND), + ): vol.In(BRANDS), vol.Required( CONF_LOGIN_METHOD, - default=self._user_auth_details.get(CONF_LOGIN_METHOD, "phone"), + default=self._user_auth_details.get( + CONF_LOGIN_METHOD, DEFAULT_LOGIN_METHOD + ), ): vol.In(LOGIN_METHODS), vol.Required( CONF_USERNAME, @@ -92,21 +125,27 @@ class AugustConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): } ), errors=errors, + description_placeholders=description_placeholders, ) - async def async_step_validation(self, user_input=None): + async def async_step_validation( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle validation (2fa) step.""" if user_input: if self._mode == "reauth": return await self.async_step_reauth_validate(user_input) return await self.async_step_user_validate(user_input) + previously_failed = VERIFICATION_CODE_KEY in self._user_auth_details return self.async_show_form( step_id="validation", data_schema=vol.Schema( {vol.Required(VERIFICATION_CODE_KEY): vol.All(str, vol.Strip)} ), + errors={"base": "invalid_verification_code"} if previously_failed else None, description_placeholders={ + CONF_BRAND: self._user_auth_details[CONF_BRAND], CONF_USERNAME: self._user_auth_details[CONF_USERNAME], CONF_LOGIN_METHOD: self._user_auth_details[CONF_LOGIN_METHOD], }, @@ -122,49 +161,84 @@ class AugustConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_reauth_validate(self, user_input=None): """Handle reauth and validation.""" - errors = {} + errors: dict[str, str] = {} + description_placeholders: dict[str, str] = {} if user_input is not None: - result = await self._async_auth_or_validate(user_input, errors) - if result is not None: - return result + self._user_auth_details.update(user_input) + validate_result = await self._async_auth_or_validate() + description_placeholders = validate_result.description_placeholders + if validate_result.validation_required: + return await self.async_step_validation() + if not (errors := validate_result.errors): + return await self._async_update_or_create_entry(validate_result.info) return self.async_show_form( step_id="reauth_validate", data_schema=vol.Schema( { + vol.Required( + CONF_BRAND, + default=self._user_auth_details.get(CONF_BRAND, DEFAULT_BRAND), + ): vol.In(BRANDS), vol.Required(CONF_PASSWORD): str, } ), errors=errors, - description_placeholders={ + description_placeholders=description_placeholders + | { CONF_USERNAME: self._user_auth_details[CONF_USERNAME], }, ) - async def _async_auth_or_validate(self, user_input, errors): - self._user_auth_details.update(user_input) - await self._august_gateway.async_setup(self._user_auth_details) + async def _async_reset_access_token_cache_if_needed( + self, gateway: AugustGateway, username: str, access_token_cache_file: str | None + ) -> None: + """Reset the access token cache if needed.""" + # We need to configure the access token cache file before we setup the gateway + # since we need to reset it if the brand changes BEFORE we setup the gateway + gateway.async_configure_access_token_cache_file( + username, access_token_cache_file + ) if self._needs_reset: self._needs_reset = False - await self._august_gateway.async_reset_authentication() + await gateway.async_reset_authentication() + + async def _async_auth_or_validate(self) -> ValidateResult: + """Authenticate or validate.""" + user_auth_details = self._user_auth_details + gateway = self._august_gateway + assert gateway is not None + await self._async_reset_access_token_cache_if_needed( + gateway, + user_auth_details[CONF_USERNAME], + user_auth_details.get(CONF_ACCESS_TOKEN_CACHE_FILE), + ) + await gateway.async_setup(user_auth_details) + + errors: dict[str, str] = {} + info: dict[str, Any] = {} + description_placeholders: dict[str, str] = {} + validation_required = False + try: - info = await async_validate_input( - self._user_auth_details, - self._august_gateway, - ) + info = await async_validate_input(user_auth_details, gateway) except CannotConnect: errors["base"] = "cannot_connect" except InvalidAuth: errors["base"] = "invalid_auth" except RequireValidation: - return await self.async_step_validation() - except Exception: # pylint: disable=broad-except + validation_required = True + except Exception as ex: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" + errors["base"] = "unhandled" + description_placeholders = {"error": str(ex)} - if errors: - return None + return ValidateResult( + validation_required, info, errors, description_placeholders + ) + async def _async_update_or_create_entry(self, info: dict[str, Any]) -> FlowResult: + """Update existing entry or create a new one.""" existing_entry = await self.async_set_unique_id( self._user_auth_details[CONF_USERNAME] ) diff --git a/homeassistant/components/august/const.py b/homeassistant/components/august/const.py index 5b936e9f1599..752499e29e28 100644 --- a/homeassistant/components/august/const.py +++ b/homeassistant/components/august/const.py @@ -7,6 +7,7 @@ from homeassistant.const import Platform DEFAULT_TIMEOUT = 25 CONF_ACCESS_TOKEN_CACHE_FILE = "access_token_cache_file" +CONF_BRAND = "brand" CONF_LOGIN_METHOD = "login_method" CONF_INSTALL_ID = "install_id" @@ -42,6 +43,7 @@ MIN_TIME_BETWEEN_DETAIL_UPDATES = timedelta(hours=1) ACTIVITY_UPDATE_INTERVAL = timedelta(seconds=10) LOGIN_METHODS = ["phone", "email"] +DEFAULT_LOGIN_METHOD = "email" PLATFORMS = [ Platform.BUTTON, diff --git a/homeassistant/components/august/diagnostics.py b/homeassistant/components/august/diagnostics.py index ffd62cd8fb72..6c19d57a0c33 100644 --- a/homeassistant/components/august/diagnostics.py +++ b/homeassistant/components/august/diagnostics.py @@ -3,12 +3,14 @@ from __future__ import annotations from typing import Any +from yalexs.const import DEFAULT_BRAND + from homeassistant.components.diagnostics import async_redact_data from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from . import AugustData -from .const import DOMAIN +from .const import CONF_BRAND, DOMAIN TO_REDACT = { "HouseID", @@ -44,4 +46,5 @@ async def async_get_config_entry_diagnostics( ) for doorbell in data.doorbells }, + "brand": entry.data.get(CONF_BRAND, DEFAULT_BRAND), } diff --git a/homeassistant/components/august/gateway.py b/homeassistant/components/august/gateway.py index 32004158f7f2..9dcf96f057af 100644 --- a/homeassistant/components/august/gateway.py +++ b/homeassistant/components/august/gateway.py @@ -1,19 +1,26 @@ """Handle August connection setup and authentication.""" import asyncio +from collections.abc import Mapping from http import HTTPStatus import logging import os +from typing import Any from aiohttp import ClientError, ClientResponseError from yalexs.api_async import ApiAsync from yalexs.authenticator_async import AuthenticationState, AuthenticatorAsync +from yalexs.authenticator_common import Authentication +from yalexs.const import DEFAULT_BRAND +from yalexs.exceptions import AugustApiAIOHTTPError from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import aiohttp_client from .const import ( CONF_ACCESS_TOKEN_CACHE_FILE, + CONF_BRAND, CONF_INSTALL_ID, CONF_LOGIN_METHOD, DEFAULT_AUGUST_CONFIG_FILE, @@ -28,48 +35,59 @@ _LOGGER = logging.getLogger(__name__) class AugustGateway: """Handle the connection to August.""" - def __init__(self, hass): + def __init__(self, hass: HomeAssistant) -> None: """Init the connection.""" # Create an aiohttp session instead of using the default one since the # default one is likely to trigger august's WAF if another integration # is also using Cloudflare self._aiohttp_session = aiohttp_client.async_create_clientsession(hass) self._token_refresh_lock = asyncio.Lock() - self._access_token_cache_file = None - self._hass = hass - self._config = None - self.api = None - self.authenticator = None - self.authentication = None + self._access_token_cache_file: str | None = None + self._hass: HomeAssistant = hass + self._config: Mapping[str, Any] | None = None + self.api: ApiAsync | None = None + self.authenticator: AuthenticatorAsync | None = None + self.authentication: Authentication | None = None @property def access_token(self): """Access token for the api.""" return self.authentication.access_token - def config_entry(self): + def config_entry(self) -> dict[str, Any]: """Config entry.""" + assert self._config is not None return { + CONF_BRAND: self._config.get(CONF_BRAND, DEFAULT_BRAND), CONF_LOGIN_METHOD: self._config[CONF_LOGIN_METHOD], CONF_USERNAME: self._config[CONF_USERNAME], CONF_INSTALL_ID: self._config.get(CONF_INSTALL_ID), CONF_ACCESS_TOKEN_CACHE_FILE: self._access_token_cache_file, } - async def async_setup(self, conf): + @callback + def async_configure_access_token_cache_file( + self, username: str, access_token_cache_file: str | None + ) -> str: + """Configure the access token cache file.""" + file = access_token_cache_file or f".{username}{DEFAULT_AUGUST_CONFIG_FILE}" + self._access_token_cache_file = file + return self._hass.config.path(file) + + async def async_setup(self, conf: Mapping[str, Any]) -> None: """Create the api and authenticator objects.""" if conf.get(VERIFICATION_CODE_KEY): return - self._access_token_cache_file = conf.get( - CONF_ACCESS_TOKEN_CACHE_FILE, - f".{conf[CONF_USERNAME]}{DEFAULT_AUGUST_CONFIG_FILE}", + access_token_cache_file_path = self.async_configure_access_token_cache_file( + conf[CONF_USERNAME], conf.get(CONF_ACCESS_TOKEN_CACHE_FILE) ) self._config = conf self.api = ApiAsync( self._aiohttp_session, timeout=self._config.get(CONF_TIMEOUT, DEFAULT_TIMEOUT), + brand=self._config.get(CONF_BRAND, DEFAULT_BRAND), ) self.authenticator = AuthenticatorAsync( @@ -78,9 +96,7 @@ class AugustGateway: self._config[CONF_USERNAME], self._config.get(CONF_PASSWORD, ""), install_id=self._config.get(CONF_INSTALL_ID), - access_token_cache_file=self._hass.config.path( - self._access_token_cache_file - ), + access_token_cache_file=access_token_cache_file_path, ) await self.authenticator.async_setup_authentication() @@ -95,6 +111,10 @@ class AugustGateway: # authenticated because we can be authenticated # by have no access await self.api.async_get_operable_locks(self.access_token) + except AugustApiAIOHTTPError as ex: + if ex.auth_failed: + raise InvalidAuth from ex + raise CannotConnect from ex except ClientResponseError as ex: if ex.status == HTTPStatus.UNAUTHORIZED: raise InvalidAuth from ex @@ -122,8 +142,9 @@ class AugustGateway: def _reset_authentication(self): """Remove the cache file.""" - if os.path.exists(self._access_token_cache_file): - os.unlink(self._access_token_cache_file) + path = self._hass.config.path(self._access_token_cache_file) + if os.path.exists(path): + os.unlink(path) async def async_refresh_access_token_if_needed(self): """Refresh the august access token if needed.""" diff --git a/homeassistant/components/august/lock.py b/homeassistant/components/august/lock.py index b11550dccd71..9e8b2470b4ec 100644 --- a/homeassistant/components/august/lock.py +++ b/homeassistant/components/august/lock.py @@ -47,6 +47,7 @@ class AugustLock(AugustEntityMixin, RestoreEntity, LockEntity): async def async_lock(self, **kwargs: Any) -> None: """Lock the device.""" + assert self._data.activity_stream is not None if self._data.activity_stream.pubnub.connected: await self._data.async_lock_async(self._device_id, self._hyper_bridge) return @@ -54,6 +55,7 @@ class AugustLock(AugustEntityMixin, RestoreEntity, LockEntity): async def async_unlock(self, **kwargs: Any) -> None: """Unlock the device.""" + assert self._data.activity_stream is not None if self._data.activity_stream.pubnub.connected: await self._data.async_unlock_async(self._device_id, self._hyper_bridge) return diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index 9f766c91df92..56965dff8508 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.3.3", "yalexs-ble==2.1.17"] + "requirements": ["yalexs==1.4.6", "yalexs-ble==2.1.17"] } diff --git a/homeassistant/components/august/strings.json b/homeassistant/components/august/strings.json index 50db556c13a8..88362c9fd661 100644 --- a/homeassistant/components/august/strings.json +++ b/homeassistant/components/august/strings.json @@ -1,7 +1,8 @@ { "config": { "error": { - "unknown": "[%key:common::config_flow::error::unknown%]", + "unhandled": "Unhandled error: {error}", + "invalid_verification_code": "Invalid verification code", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" }, @@ -15,20 +16,22 @@ "data": { "code": "Verification code" }, - "description": "Please check your {login_method} ({username}) and enter the verification code below" + "description": "Please check your {login_method} ({username}) and enter the verification code below. Codes may take a few minutes to arrive." }, "user_validate": { - "description": "If the Login Method is 'email', Username is the email address. If the Login Method is 'phone', Username is the phone number in the format '+NNNNNNNNN'.", + "description": "It is recommended to use the 'email' login method as some brands may not work with the 'phone' method. If the Login Method is 'email', Username is the email address. If the Login Method is 'phone', Username is the phone number in the format '+NNNNNNNNN'. If you choose the wrong brand, you may be able to authenticate initially; however, you will not be able to operate devices. If you are unsure of the brand, create the integration again and try another brand.", "data": { - "password": "[%key:common::config_flow::data::password%]", + "brand": "Brand", + "login_method": "Login Method", "username": "[%key:common::config_flow::data::username%]", - "login_method": "Login Method" + "password": "[%key:common::config_flow::data::password%]" }, "title": "Set up an August account" }, "reauth_validate": { - "description": "Enter the password for {username}.", + "description": "Choose the correct brand for your device, and enter the password for {username}. If you choose the wrong brand, you may be able to authenticate initially; however, you will not be able to operate devices. If you are unsure of the brand, create the integration again and try another brand.", "data": { + "brand": "[%key:component::august::config::step::user_validate::data::brand%]", "password": "[%key:common::config_flow::data::password%]" }, "title": "Reauthenticate an August account" diff --git a/requirements_all.txt b/requirements_all.txt index ea1c2bc9aa42..4cab4187475d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2697,7 +2697,7 @@ yalesmartalarmclient==0.3.9 yalexs-ble==2.1.17 # homeassistant.components.august -yalexs==1.3.3 +yalexs==1.4.6 # homeassistant.components.yeelight yeelight==0.7.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ba3daab6aa3e..3dbde56127a5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1964,7 +1964,7 @@ yalesmartalarmclient==0.3.9 yalexs-ble==2.1.17 # homeassistant.components.august -yalexs==1.3.3 +yalexs==1.4.6 # homeassistant.components.yeelight yeelight==0.7.10 diff --git a/tests/components/august/test_config_flow.py b/tests/components/august/test_config_flow.py index 241dd36a5e8d..f30828a5d72a 100644 --- a/tests/components/august/test_config_flow.py +++ b/tests/components/august/test_config_flow.py @@ -6,6 +6,7 @@ from yalexs.authenticator import ValidationResult from homeassistant import config_entries from homeassistant.components.august.const import ( CONF_ACCESS_TOKEN_CACHE_FILE, + CONF_BRAND, CONF_INSTALL_ID, CONF_LOGIN_METHOD, DOMAIN, @@ -18,6 +19,7 @@ from homeassistant.components.august.exceptions import ( ) from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry @@ -28,7 +30,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"] is FlowResultType.FORM assert result["errors"] == {} with patch( @@ -41,6 +43,7 @@ async def test_form(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { + CONF_BRAND: "august", CONF_LOGIN_METHOD: "email", CONF_USERNAME: "my@email.tld", CONF_PASSWORD: "test-password", @@ -48,9 +51,10 @@ async def test_form(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert result2["type"] == "create_entry" + assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "my@email.tld" assert result2["data"] == { + CONF_BRAND: "august", CONF_LOGIN_METHOD: "email", CONF_USERNAME: "my@email.tld", CONF_INSTALL_ID: None, @@ -72,13 +76,14 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { + CONF_BRAND: "august", CONF_LOGIN_METHOD: "email", CONF_USERNAME: "my@email.tld", CONF_PASSWORD: "test-password", }, ) - assert result2["type"] == "form" + assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "invalid_auth"} @@ -90,19 +95,21 @@ async def test_user_unexpected_exception(hass: HomeAssistant) -> None: with patch( "homeassistant.components.august.config_flow.AugustGateway.async_authenticate", - side_effect=ValueError, + side_effect=ValueError("something exploded"), ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { + CONF_BRAND: "august", CONF_LOGIN_METHOD: "email", CONF_USERNAME: "my@email.tld", CONF_PASSWORD: "test-password", }, ) - assert result2["type"] == "form" - assert result2["errors"] == {"base": "unknown"} + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "unhandled"} + assert result2["description_placeholders"] == {"error": "something exploded"} async def test_form_cannot_connect(hass: HomeAssistant) -> None: @@ -124,7 +131,7 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: }, ) - assert result2["type"] == "form" + assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "cannot_connect"} @@ -151,7 +158,7 @@ async def test_form_needs_validate(hass: HomeAssistant) -> None: ) assert len(mock_send_verification_code.mock_calls) == 1 - assert result2["type"] == "form" + assert result2["type"] is FlowResultType.FORM assert result2["errors"] is None assert result2["step_id"] == "validation" @@ -165,9 +172,7 @@ async def test_form_needs_validate(hass: HomeAssistant) -> None: ) as mock_validate_verification_code, patch( "homeassistant.components.august.gateway.AuthenticatorAsync.async_send_verification_code", return_value=True, - ) as mock_send_verification_code, patch( - "homeassistant.components.august.async_setup_entry", return_value=True - ) as mock_setup_entry: + ) as mock_send_verification_code: result3 = await hass.config_entries.flow.async_configure( result["flow_id"], {VERIFICATION_CODE_KEY: "incorrect"}, @@ -177,8 +182,8 @@ async def test_form_needs_validate(hass: HomeAssistant) -> None: # so they have a chance to retry assert len(mock_send_verification_code.mock_calls) == 0 assert len(mock_validate_verification_code.mock_calls) == 1 - assert result3["type"] == "form" - assert result3["errors"] is None + assert result3["type"] is FlowResultType.FORM + assert result3["errors"] == {"base": "invalid_verification_code"} assert result3["step_id"] == "validation" # Try with the CORRECT verification code and we setup @@ -202,9 +207,10 @@ async def test_form_needs_validate(hass: HomeAssistant) -> None: assert len(mock_send_verification_code.mock_calls) == 0 assert len(mock_validate_verification_code.mock_calls) == 1 - assert result4["type"] == "create_entry" + assert result4["type"] is FlowResultType.CREATE_ENTRY assert result4["title"] == "my@email.tld" assert result4["data"] == { + CONF_BRAND: "august", CONF_LOGIN_METHOD: "email", CONF_USERNAME: "my@email.tld", CONF_INSTALL_ID: None, @@ -233,7 +239,7 @@ async def test_form_reauth(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_REAUTH}, data=entry.data ) - assert result["type"] == "form" + assert result["type"] is FlowResultType.FORM assert result["errors"] == {} with patch( @@ -251,7 +257,7 @@ async def test_form_reauth(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert result2["type"] == "abort" + assert result2["type"] is FlowResultType.ABORT assert result2["reason"] == "reauth_successful" assert len(mock_setup_entry.mock_calls) == 1 @@ -276,7 +282,7 @@ async def test_form_reauth_with_2fa(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_REAUTH}, data=entry.data ) - assert result["type"] == "form" + assert result["type"] is FlowResultType.FORM assert result["errors"] == {} with patch( @@ -295,7 +301,7 @@ async def test_form_reauth_with_2fa(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert len(mock_send_verification_code.mock_calls) == 1 - assert result2["type"] == "form" + assert result2["type"] is FlowResultType.FORM assert result2["errors"] is None assert result2["step_id"] == "validation" @@ -320,6 +326,52 @@ async def test_form_reauth_with_2fa(hass: HomeAssistant) -> None: assert len(mock_validate_verification_code.mock_calls) == 1 assert len(mock_send_verification_code.mock_calls) == 0 - assert result3["type"] == "abort" + assert result3["type"] is FlowResultType.ABORT assert result3["reason"] == "reauth_successful" assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_switching_brands(hass: HomeAssistant) -> None: + """Test brands can be switched by setting up again.""" + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_LOGIN_METHOD: "email", + CONF_USERNAME: "my@email.tld", + CONF_PASSWORD: "test-password", + CONF_INSTALL_ID: None, + CONF_TIMEOUT: 10, + CONF_ACCESS_TOKEN_CACHE_FILE: ".my@email.tld.august.conf", + }, + unique_id="my@email.tld", + ) + entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.august.config_flow.AugustGateway.async_authenticate", + return_value=True, + ), patch( + "homeassistant.components.august.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_BRAND: "yale_home", + CONF_LOGIN_METHOD: "email", + CONF_USERNAME: "my@email.tld", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + assert len(mock_setup_entry.mock_calls) == 1 + assert entry.data[CONF_BRAND] == "yale_home" diff --git a/tests/components/august/test_diagnostics.py b/tests/components/august/test_diagnostics.py index 48a1f62875ba..c15ccfd0119c 100644 --- a/tests/components/august/test_diagnostics.py +++ b/tests/components/august/test_diagnostics.py @@ -141,4 +141,5 @@ async def test_diagnostics( "zWaveEnabled": False, } }, + "brand": "august", } diff --git a/tests/components/august/test_init.py b/tests/components/august/test_init.py index 80f91dc37eed..23ea12a9f827 100644 --- a/tests/components/august/test_init.py +++ b/tests/components/august/test_init.py @@ -77,12 +77,42 @@ async def test_august_is_offline(hass: HomeAssistant) -> None: assert config_entry.state is ConfigEntryState.SETUP_RETRY +async def test_august_late_auth_failure(hass: HomeAssistant) -> None: + """Test we can detect a late auth failure.""" + aiohttp_client_response_exception = ClientResponseError(None, None, status=401) + config_entry = MockConfigEntry( + domain=DOMAIN, + data=_mock_get_config()[DOMAIN], + title="August august", + ) + config_entry.add_to_hass(hass) + + with patch( + "yalexs.authenticator_async.AuthenticatorAsync.async_authenticate", + side_effect=AugustApiAIOHTTPError( + "This should bubble up as its user consumable", + aiohttp_client_response_exception, + ), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_ERROR + flows = hass.config_entries.flow.async_progress() + + assert flows[0]["step_id"] == "reauth_validate" + + async def test_unlock_throws_august_api_http_error(hass: HomeAssistant) -> None: """Test unlock throws correct error on http error.""" mocked_lock_detail = await _mock_operative_august_lock_detail(hass) + aiohttp_client_response_exception = ClientResponseError(None, None, status=400) def _unlock_return_activities_side_effect(access_token, device_id): - raise AugustApiAIOHTTPError("This should bubble up as its user consumable") + raise AugustApiAIOHTTPError( + "This should bubble up as its user consumable", + aiohttp_client_response_exception, + ) await _create_august_with_devices( hass, @@ -106,9 +136,13 @@ async def test_unlock_throws_august_api_http_error(hass: HomeAssistant) -> None: async def test_lock_throws_august_api_http_error(hass: HomeAssistant) -> None: """Test lock throws correct error on http error.""" mocked_lock_detail = await _mock_operative_august_lock_detail(hass) + aiohttp_client_response_exception = ClientResponseError(None, None, status=400) def _lock_return_activities_side_effect(access_token, device_id): - raise AugustApiAIOHTTPError("This should bubble up as its user consumable") + raise AugustApiAIOHTTPError( + "This should bubble up as its user consumable", + aiohttp_client_response_exception, + ) await _create_august_with_devices( hass, From cbee514c2a5e16ae1997219ab3428cb806f48e5a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 20 May 2023 14:26:33 -0500 Subject: [PATCH 410/822] Bump sqlalchemy to 2.0.15 (#93293) --- homeassistant/components/recorder/manifest.json | 2 +- homeassistant/components/sql/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index a932131cb03b..33c6a516c658 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.13", + "sqlalchemy==2.0.15", "fnv-hash-fast==0.3.1", "psutil-home-assistant==0.0.1" ] diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index fd48bd100945..4d3e95d6b309 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.13"] + "requirements": ["sqlalchemy==2.0.15"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index de886306e5a6..39d53b69dcea 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -45,7 +45,7 @@ pyudev==0.23.2 pyyaml==6.0 requests==2.29.0 scapy==2.5.0 -sqlalchemy==2.0.13 +sqlalchemy==2.0.15 typing-extensions>=4.5.0,<5.0 ulid-transform==0.7.2 voluptuous-serialize==2.6.0 diff --git a/requirements_all.txt b/requirements_all.txt index 4cab4187475d..fc654fc4e3da 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2412,7 +2412,7 @@ spotipy==2.23.0 # homeassistant.components.recorder # homeassistant.components.sql -sqlalchemy==2.0.13 +sqlalchemy==2.0.15 # homeassistant.components.srp_energy srpenergy==1.3.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3dbde56127a5..01d6b188227d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1748,7 +1748,7 @@ spotipy==2.23.0 # homeassistant.components.recorder # homeassistant.components.sql -sqlalchemy==2.0.13 +sqlalchemy==2.0.15 # homeassistant.components.srp_energy srpenergy==1.3.6 From fc7a421a48dc6bb041a450398c70fc434b5299f9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 20 May 2023 14:41:16 -0500 Subject: [PATCH 411/822] Fix lookin falling back to polling too quickly (#93227) --- homeassistant/components/lookin/__init__.py | 17 +++++++++-------- homeassistant/components/lookin/const.py | 10 ++++++++++ homeassistant/components/lookin/coordinator.py | 9 +++------ 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/lookin/__init__.py b/homeassistant/components/lookin/__init__.py index a8f22fb17ca2..c16d7f34f0f1 100644 --- a/homeassistant/components/lookin/__init__.py +++ b/homeassistant/components/lookin/__init__.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio from collections.abc import Callable, Coroutine -from datetime import timedelta import logging from typing import Any @@ -26,7 +25,13 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN, PLATFORMS, TYPE_TO_PLATFORM +from .const import ( + DOMAIN, + METEO_UPDATE_INTERVAL, + PLATFORMS, + REMOTE_UPDATE_INTERVAL, + TYPE_TO_PLATFORM, +) from .coordinator import LookinDataUpdateCoordinator, LookinPushCoordinator from .models import LookinData @@ -107,9 +112,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: push_coordinator, name=entry.title, update_method=lookin_protocol.get_meteo_sensor, - update_interval=timedelta( - minutes=5 - ), # Updates are pushed (fallback is polling) + update_interval=METEO_UPDATE_INTERVAL, # Updates are pushed (fallback is polling) ) await meteo_coordinator.async_config_entry_first_refresh() @@ -127,9 +130,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: push_coordinator, name=f"{entry.title} {uuid}", update_method=updater, - update_interval=timedelta( - seconds=60 - ), # Updates are pushed (fallback is polling) + update_interval=REMOTE_UPDATE_INTERVAL, # Updates are pushed (fallback is polling) ) await coordinator.async_config_entry_first_refresh() device_coordinators[uuid] = coordinator diff --git a/homeassistant/components/lookin/const.py b/homeassistant/components/lookin/const.py index a48e3ad01a84..8eb96dcefd83 100644 --- a/homeassistant/components/lookin/const.py +++ b/homeassistant/components/lookin/const.py @@ -1,6 +1,7 @@ """The lookin integration constants.""" from __future__ import annotations +from datetime import timedelta from typing import Final from homeassistant.const import Platform @@ -22,3 +23,12 @@ TYPE_TO_PLATFORM = { "03": Platform.LIGHT, "EF": Platform.CLIMATE, } + +NEVER_TIME = -120.0 # Time that will never match time.monotonic() +ACTIVE_UPDATES_INTERVAL = 4 # Consider active for 4x the update interval +METEO_UPDATE_INTERVAL = timedelta(minutes=5) +REMOTE_UPDATE_INTERVAL = timedelta(seconds=60) +POLLING_FALLBACK_SECONDS = ( + max(REMOTE_UPDATE_INTERVAL, METEO_UPDATE_INTERVAL).total_seconds() + * ACTIVE_UPDATES_INTERVAL +) diff --git a/homeassistant/components/lookin/coordinator.py b/homeassistant/components/lookin/coordinator.py index 94c4a70f3caa..1bdbb36dd71c 100644 --- a/homeassistant/components/lookin/coordinator.py +++ b/homeassistant/components/lookin/coordinator.py @@ -10,12 +10,11 @@ from typing import TypeVar from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from .const import NEVER_TIME, POLLING_FALLBACK_SECONDS + _LOGGER = logging.getLogger(__name__) _DataT = TypeVar("_DataT") -NEVER_TIME = -120.0 # Time that will never match time.monotonic() -ACTIVE_UPDATES_INTERVAL = 3 # Consider active for 3x the update interval - class LookinPushCoordinator: """Keep track of when the last push update was.""" @@ -32,9 +31,7 @@ class LookinPushCoordinator: def active(self, interval: timedelta) -> bool: """Check if the last push update was recently.""" time_since_last_update = time.monotonic() - self.last_update - is_active = ( - time_since_last_update < interval.total_seconds() * ACTIVE_UPDATES_INTERVAL - ) + is_active = time_since_last_update < POLLING_FALLBACK_SECONDS _LOGGER.debug( "%s: push updates active: %s (time_since_last_update=%s)", self.name, From ddb9a6e33c374cdcec22537bc647854c10d87d8d Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 21 May 2023 10:11:08 +0200 Subject: [PATCH 412/822] Add change service to timer (#84775) * Add change service * test subtract * Test no change if timer not running * Modify example * Raise * Finalize * test event * Fix tests * Fix tracking time --- homeassistant/components/timer/__init__.py | 27 +++ homeassistant/components/timer/services.yaml | 15 ++ tests/components/timer/test_init.py | 197 ++++++++++++++++--- 3 files changed, 217 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/timer/__init__.py b/homeassistant/components/timer/__init__.py index 7cb2c10425ec..90ad5e0491b8 100644 --- a/homeassistant/components/timer/__init__.py +++ b/homeassistant/components/timer/__init__.py @@ -17,6 +17,7 @@ from homeassistant.const import ( SERVICE_RELOAD, ) from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import collection import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_component import EntityComponent @@ -50,6 +51,7 @@ STATUS_PAUSED = "paused" EVENT_TIMER_FINISHED = "timer.finished" EVENT_TIMER_CANCELLED = "timer.cancelled" +EVENT_TIMER_CHANGED = "timer.changed" EVENT_TIMER_STARTED = "timer.started" EVENT_TIMER_RESTARTED = "timer.restarted" EVENT_TIMER_PAUSED = "timer.paused" @@ -57,6 +59,7 @@ EVENT_TIMER_PAUSED = "timer.paused" SERVICE_START = "start" SERVICE_PAUSE = "pause" SERVICE_CANCEL = "cancel" +SERVICE_CHANGE = "change" SERVICE_FINISH = "finish" STORAGE_KEY = DOMAIN @@ -158,6 +161,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: component.async_register_entity_service(SERVICE_PAUSE, {}, "async_pause") component.async_register_entity_service(SERVICE_CANCEL, {}, "async_cancel") component.async_register_entity_service(SERVICE_FINISH, {}, "async_finish") + component.async_register_entity_service( + SERVICE_CHANGE, + {vol.Optional(ATTR_DURATION, default=DEFAULT_DURATION): cv.time_period}, + "async_change", + ) return True @@ -321,6 +329,25 @@ class Timer(collection.CollectionEntity, RestoreEntity): ) self.async_write_ha_state() + @callback + def async_change(self, duration: timedelta) -> None: + """Change duration of a running timer.""" + if self._listener is None or self._end is None: + raise HomeAssistantError( + f"Timer {self.entity_id} is not running, only active timers can be changed" + ) + + self._listener() + self._listener = None + self._end += duration + self._duration += duration + self._remaining = self._end - dt_util.utcnow().replace(microsecond=0) + self.hass.bus.async_fire(EVENT_TIMER_CHANGED, {ATTR_ENTITY_ID: self.entity_id}) + self._listener = async_track_point_in_utc_time( + self.hass, self._async_finished, self._end + ) + self.async_write_ha_state() + @callback def async_pause(self): """Pause a timer.""" diff --git a/homeassistant/components/timer/services.yaml b/homeassistant/components/timer/services.yaml index e17ea1dd5fb2..68caa44a6990 100644 --- a/homeassistant/components/timer/services.yaml +++ b/homeassistant/components/timer/services.yaml @@ -33,3 +33,18 @@ finish: target: entity: domain: timer + +change: + name: Change + description: Change a timer + target: + entity: + domain: timer + fields: + duration: + description: Duration to add or subtract to the running timer + default: 0 + required: true + example: "00:01:00, 60 or -60" + selector: + text: diff --git a/tests/components/timer/test_init.py b/tests/components/timer/test_init.py index 93b0463e800b..a60e42eb7681 100644 --- a/tests/components/timer/test_init.py +++ b/tests/components/timer/test_init.py @@ -17,11 +17,13 @@ from homeassistant.components.timer import ( DEFAULT_DURATION, DOMAIN, EVENT_TIMER_CANCELLED, + EVENT_TIMER_CHANGED, EVENT_TIMER_FINISHED, EVENT_TIMER_PAUSED, EVENT_TIMER_RESTARTED, EVENT_TIMER_STARTED, SERVICE_CANCEL, + SERVICE_CHANGE, SERVICE_FINISH, SERVICE_PAUSE, SERVICE_START, @@ -43,7 +45,7 @@ from homeassistant.const import ( SERVICE_RELOAD, ) from homeassistant.core import Context, CoreState, HomeAssistant, State -from homeassistant.exceptions import Unauthorized +from homeassistant.exceptions import HomeAssistantError, Unauthorized from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.restore_state import ( DATA_RESTORE_STATE_TASK, @@ -60,7 +62,7 @@ _LOGGER = logging.getLogger(__name__) @pytest.fixture -def storage_setup(hass, hass_storage): +def storage_setup(hass: HomeAssistant, hass_storage): """Storage setup.""" async def _storage(items=None, config=None): @@ -168,26 +170,91 @@ async def test_methods_and_events(hass: HomeAssistant) -> None: hass.bus.async_listen(EVENT_TIMER_PAUSED, fake_event_listener) hass.bus.async_listen(EVENT_TIMER_FINISHED, fake_event_listener) hass.bus.async_listen(EVENT_TIMER_CANCELLED, fake_event_listener) + hass.bus.async_listen(EVENT_TIMER_CHANGED, fake_event_listener) steps = [ - {"call": SERVICE_START, "state": STATUS_ACTIVE, "event": EVENT_TIMER_STARTED}, - {"call": SERVICE_PAUSE, "state": STATUS_PAUSED, "event": EVENT_TIMER_PAUSED}, - {"call": SERVICE_START, "state": STATUS_ACTIVE, "event": EVENT_TIMER_RESTARTED}, - {"call": SERVICE_CANCEL, "state": STATUS_IDLE, "event": EVENT_TIMER_CANCELLED}, - {"call": SERVICE_START, "state": STATUS_ACTIVE, "event": EVENT_TIMER_STARTED}, - {"call": SERVICE_FINISH, "state": STATUS_IDLE, "event": EVENT_TIMER_FINISHED}, - {"call": SERVICE_START, "state": STATUS_ACTIVE, "event": EVENT_TIMER_STARTED}, - {"call": SERVICE_PAUSE, "state": STATUS_PAUSED, "event": EVENT_TIMER_PAUSED}, - {"call": SERVICE_CANCEL, "state": STATUS_IDLE, "event": EVENT_TIMER_CANCELLED}, - {"call": SERVICE_START, "state": STATUS_ACTIVE, "event": EVENT_TIMER_STARTED}, - {"call": SERVICE_START, "state": STATUS_ACTIVE, "event": EVENT_TIMER_RESTARTED}, + { + "call": SERVICE_START, + "state": STATUS_ACTIVE, + "event": EVENT_TIMER_STARTED, + "data": {}, + }, + { + "call": SERVICE_PAUSE, + "state": STATUS_PAUSED, + "event": EVENT_TIMER_PAUSED, + "data": {}, + }, + { + "call": SERVICE_START, + "state": STATUS_ACTIVE, + "event": EVENT_TIMER_RESTARTED, + "data": {}, + }, + { + "call": SERVICE_CANCEL, + "state": STATUS_IDLE, + "event": EVENT_TIMER_CANCELLED, + "data": {}, + }, + { + "call": SERVICE_START, + "state": STATUS_ACTIVE, + "event": EVENT_TIMER_STARTED, + "data": {}, + }, + { + "call": SERVICE_FINISH, + "state": STATUS_IDLE, + "event": EVENT_TIMER_FINISHED, + "data": {}, + }, + { + "call": SERVICE_START, + "state": STATUS_ACTIVE, + "event": EVENT_TIMER_STARTED, + "data": {}, + }, + { + "call": SERVICE_PAUSE, + "state": STATUS_PAUSED, + "event": EVENT_TIMER_PAUSED, + "data": {}, + }, + { + "call": SERVICE_CANCEL, + "state": STATUS_IDLE, + "event": EVENT_TIMER_CANCELLED, + "data": {}, + }, + { + "call": SERVICE_START, + "state": STATUS_ACTIVE, + "event": EVENT_TIMER_STARTED, + "data": {}, + }, + { + "call": SERVICE_CHANGE, + "state": STATUS_ACTIVE, + "event": EVENT_TIMER_CHANGED, + "data": {CONF_DURATION: 15}, + }, + { + "call": SERVICE_START, + "state": STATUS_ACTIVE, + "event": EVENT_TIMER_RESTARTED, + "data": {}, + }, ] - expectedEvents = 0 + expected_events = 0 for step in steps: if step["call"] is not None: await hass.services.async_call( - DOMAIN, step["call"], {CONF_ENTITY_ID: "timer.test1"} + DOMAIN, + step["call"], + {CONF_ENTITY_ID: "timer.test1", **step["data"]}, + blocking=True, ) await hass.async_block_till_done() @@ -197,9 +264,9 @@ async def test_methods_and_events(hass: HomeAssistant) -> None: assert state.state == step["state"] if step["event"] is not None: - expectedEvents += 1 + expected_events += 1 assert results[-1].event_type == step["event"] - assert len(results) == expectedEvents + assert len(results) == expected_events async def test_start_service(hass: HomeAssistant) -> None: @@ -212,7 +279,7 @@ async def test_start_service(hass: HomeAssistant) -> None: assert state.attributes[ATTR_DURATION] == "0:00:10" await hass.services.async_call( - DOMAIN, SERVICE_START, {CONF_ENTITY_ID: "timer.test1"} + DOMAIN, SERVICE_START, {CONF_ENTITY_ID: "timer.test1"}, blocking=True ) await hass.async_block_till_done() state = hass.states.get("timer.test1") @@ -222,7 +289,7 @@ async def test_start_service(hass: HomeAssistant) -> None: assert state.attributes[ATTR_REMAINING] == "0:00:10" await hass.services.async_call( - DOMAIN, SERVICE_CANCEL, {CONF_ENTITY_ID: "timer.test1"} + DOMAIN, SERVICE_CANCEL, {CONF_ENTITY_ID: "timer.test1"}, blocking=True ) await hass.async_block_till_done() state = hass.states.get("timer.test1") @@ -231,8 +298,20 @@ async def test_start_service(hass: HomeAssistant) -> None: assert state.attributes[ATTR_DURATION] == "0:00:10" assert ATTR_REMAINING not in state.attributes + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_CHANGE, + {CONF_ENTITY_ID: "timer.test1", CONF_DURATION: 10}, + blocking=True, + ) + await hass.async_block_till_done() + await hass.services.async_call( - DOMAIN, SERVICE_START, {CONF_ENTITY_ID: "timer.test1", CONF_DURATION: 15} + DOMAIN, + SERVICE_START, + {CONF_ENTITY_ID: "timer.test1", CONF_DURATION: 15}, + blocking=True, ) await hass.async_block_till_done() state = hass.states.get("timer.test1") @@ -241,6 +320,57 @@ async def test_start_service(hass: HomeAssistant) -> None: assert state.attributes[ATTR_DURATION] == "0:00:15" assert state.attributes[ATTR_REMAINING] == "0:00:15" + await hass.services.async_call( + DOMAIN, + SERVICE_CHANGE, + {CONF_ENTITY_ID: "timer.test1", CONF_DURATION: 15}, + blocking=True, + ) + await hass.async_block_till_done() + state = hass.states.get("timer.test1") + assert state + assert state.state == STATUS_ACTIVE + assert state.attributes[ATTR_DURATION] == "0:00:30" + assert state.attributes[ATTR_REMAINING] == "0:00:30" + + await hass.services.async_call( + DOMAIN, + SERVICE_CHANGE, + {CONF_ENTITY_ID: "timer.test1", CONF_DURATION: -10}, + blocking=True, + ) + await hass.async_block_till_done() + state = hass.states.get("timer.test1") + assert state + assert state.state == STATUS_ACTIVE + assert state.attributes[ATTR_DURATION] == "0:00:20" + assert state.attributes[ATTR_REMAINING] == "0:00:20" + + await hass.services.async_call( + DOMAIN, SERVICE_CANCEL, {CONF_ENTITY_ID: "timer.test1"}, blocking=True + ) + await hass.async_block_till_done() + state = hass.states.get("timer.test1") + assert state + assert state.state == STATUS_IDLE + assert state.attributes[ATTR_DURATION] == "0:00:20" + assert ATTR_REMAINING not in state.attributes + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_CHANGE, + {CONF_ENTITY_ID: "timer.test1", CONF_DURATION: 10}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("timer.test1") + assert state + assert state.state == STATUS_IDLE + assert state.attributes[ATTR_DURATION] == "0:00:20" + assert ATTR_REMAINING not in state.attributes + async def test_wait_till_timer_expires(hass: HomeAssistant) -> None: """Test for a timer to end.""" @@ -262,9 +392,10 @@ async def test_wait_till_timer_expires(hass: HomeAssistant) -> None: hass.bus.async_listen(EVENT_TIMER_PAUSED, fake_event_listener) hass.bus.async_listen(EVENT_TIMER_FINISHED, fake_event_listener) hass.bus.async_listen(EVENT_TIMER_CANCELLED, fake_event_listener) + hass.bus.async_listen(EVENT_TIMER_CHANGED, fake_event_listener) await hass.services.async_call( - DOMAIN, SERVICE_START, {CONF_ENTITY_ID: "timer.test1"} + DOMAIN, SERVICE_START, {CONF_ENTITY_ID: "timer.test1"}, blocking=True ) await hass.async_block_till_done() @@ -275,15 +406,37 @@ async def test_wait_till_timer_expires(hass: HomeAssistant) -> None: assert results[-1].event_type == EVENT_TIMER_STARTED assert len(results) == 1 + await hass.services.async_call( + DOMAIN, + SERVICE_CHANGE, + {CONF_ENTITY_ID: "timer.test1", CONF_DURATION: 10}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("timer.test1") + assert state + assert state.state == STATUS_ACTIVE + + assert results[-1].event_type == EVENT_TIMER_CHANGED + assert len(results) == 2 + async_fire_time_changed(hass, utcnow() + timedelta(seconds=10)) await hass.async_block_till_done() + state = hass.states.get("timer.test1") + assert state + assert state.state == STATUS_ACTIVE + + async_fire_time_changed(hass, utcnow() + timedelta(seconds=20)) + await hass.async_block_till_done() + state = hass.states.get("timer.test1") assert state assert state.state == STATUS_IDLE assert results[-1].event_type == EVENT_TIMER_FINISHED - assert len(results) == 2 + assert len(results) == 3 async def test_no_initial_state_and_no_restore_state(hass: HomeAssistant) -> None: From e676cbe9f2be1e4712348ef0656a587af143023d Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 21 May 2023 10:13:36 +0200 Subject: [PATCH 413/822] Fix import issue in Workday (#92727) * Workday fix import * Messages --- .../components/workday/config_flow.py | 19 +++++++++++++++++-- homeassistant/components/workday/strings.json | 3 ++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/workday/config_flow.py b/homeassistant/components/workday/config_flow.py index a66d965a3513..c440ca5d73e2 100644 --- a/homeassistant/components/workday/config_flow.py +++ b/homeassistant/components/workday/config_flow.py @@ -40,6 +40,7 @@ from .const import ( DEFAULT_OFFSET, DEFAULT_WORKDAYS, DOMAIN, + LOGGER, ) NONE_SENTINEL = "none" @@ -168,8 +169,17 @@ class WorkdayConfigFlow(ConfigFlow, domain=DOMAIN): } new_config = config.copy() new_config[CONF_PROVINCE] = config.get(CONF_PROVINCE) + LOGGER.debug("Importing with %s", new_config) self._async_abort_entries_match(abort_match) + + self.data[CONF_NAME] = config.get(CONF_NAME, DEFAULT_NAME) + self.data[CONF_COUNTRY] = config[CONF_COUNTRY] + LOGGER.debug( + "No duplicate, next step with name %s for country %s", + self.data[CONF_NAME], + self.data[CONF_COUNTRY], + ) return await self.async_step_options(user_input=new_config) async def async_step_user( @@ -217,9 +227,12 @@ class WorkdayConfigFlow(ConfigFlow, domain=DOMAIN): CONF_REMOVE_HOLIDAYS: combined_input[CONF_REMOVE_HOLIDAYS], CONF_PROVINCE: combined_input[CONF_PROVINCE], } - + LOGGER.debug("abort_check in options with %s", combined_input) self._async_abort_entries_match(abort_match) + + LOGGER.debug("Errors have occurred %s", errors) if not errors: + LOGGER.debug("No duplicate, no errors, creating entry") return self.async_create_entry( title=combined_input[CONF_NAME], data={}, @@ -234,6 +247,7 @@ class WorkdayConfigFlow(ConfigFlow, domain=DOMAIN): step_id="options", data_schema=new_schema, errors=errors, + description_placeholders={"name": self.data[CONF_NAME]}, ) @@ -260,6 +274,7 @@ class WorkdayOptionsFlowHandler(OptionsFlowWithConfigEntry): except RemoveDatesError: errors["remove_holidays"] = "remove_holiday_error" else: + LOGGER.debug("abort_check in options with %s", combined_input) try: self._async_abort_entries_match( { @@ -284,7 +299,7 @@ class WorkdayOptionsFlowHandler(OptionsFlowWithConfigEntry): new_schema = self.add_suggested_values_to_schema( schema, user_input or self.options ) - + LOGGER.debug("Errors have occurred in options %s", errors) return self.async_show_form( step_id="init", data_schema=new_schema, diff --git a/homeassistant/components/workday/strings.json b/homeassistant/components/workday/strings.json index 8a516602b56b..b81e027bb051 100644 --- a/homeassistant/components/workday/strings.json +++ b/homeassistant/components/workday/strings.json @@ -2,7 +2,7 @@ "config": { "abort": { "incorrect_province": "Incorrect subdivision from yaml import", - "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + "already_configured": "Workday has already been setup with chosen configuration" }, "step": { "user": { @@ -12,6 +12,7 @@ } }, "options": { + "description": "Set workday options for {name}", "data": { "excludes": "Excludes", "days_offset": "Offset", From 72c76d35cceaac4edd53f6fedd374831f0011c7a Mon Sep 17 00:00:00 2001 From: rikroe <42204099+rikroe@users.noreply.github.com> Date: Sun, 21 May 2023 14:16:47 +0200 Subject: [PATCH 414/822] Bump bimmer_connected to 0.13.6 (#93307) --- homeassistant/components/bmw_connected_drive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index c600a1529a9c..c9612d00c643 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", "iot_class": "cloud_polling", "loggers": ["bimmer_connected"], - "requirements": ["bimmer_connected==0.13.5"] + "requirements": ["bimmer_connected==0.13.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index fc654fc4e3da..07fbb1d88d4e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -431,7 +431,7 @@ beautifulsoup4==4.11.1 bellows==0.35.5 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.13.5 +bimmer_connected==0.13.6 # homeassistant.components.bizkaibus bizkaibus==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 01d6b188227d..ada42531b2e6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -364,7 +364,7 @@ beautifulsoup4==4.11.1 bellows==0.35.5 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.13.5 +bimmer_connected==0.13.6 # homeassistant.components.bluetooth bleak-retry-connector==3.0.2 From 780f9bcdc0df287621504c32b65e220704e5dbba Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 May 2023 07:17:34 -0500 Subject: [PATCH 415/822] Fix shutdown being delayed for cancelling tasks (#93287) --- homeassistant/core.py | 14 +++++++++++--- homeassistant/util/async_.py | 9 ++++++++- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index 8e88820bf708..b2a00cce038e 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -82,7 +82,11 @@ from .exceptions import ( from .helpers.aiohttp_compat import restore_original_aiohttp_cancel_behavior from .helpers.json import json_dumps from .util import dt as dt_util, location, ulid as ulid_util -from .util.async_ import run_callback_threadsafe, shutdown_run_callback_threadsafe +from .util.async_ import ( + cancelling, + run_callback_threadsafe, + shutdown_run_callback_threadsafe, +) from .util.read_only_dict import ReadOnlyDict from .util.timeout import TimeoutManager from .util.unit_system import ( @@ -678,7 +682,11 @@ class HomeAssistant: start_time: float | None = None current_task = asyncio.current_task() - while tasks := [task for task in self._tasks if task is not current_task]: + while tasks := [ + task + for task in self._tasks + if task is not current_task and not cancelling(task) + ]: await self._await_and_log_pending(tasks) if start_time is None: @@ -791,7 +799,7 @@ class HomeAssistant: # while we are awaiting canceled tasks to get their result # which will result in the set size changing during iteration for task in list(running_tasks): - if task.done(): + if task.done() or cancelling(task): # Since we made a copy we need to check # to see if the task finished while we # were awaiting another task diff --git a/homeassistant/util/async_.py b/homeassistant/util/async_.py index 5b119a58c222..4caf074b8794 100644 --- a/homeassistant/util/async_.py +++ b/homeassistant/util/async_.py @@ -1,7 +1,7 @@ """Asyncio utilities.""" from __future__ import annotations -from asyncio import Semaphore, gather, get_running_loop +from asyncio import Future, Semaphore, gather, get_running_loop from asyncio.events import AbstractEventLoop from collections.abc import Awaitable, Callable import concurrent.futures @@ -20,6 +20,13 @@ _R = TypeVar("_R") _P = ParamSpec("_P") +def cancelling(task: Future[Any]) -> bool: + """Return True if task is done or cancelling.""" + # https://docs.python.org/3/library/asyncio-task.html#asyncio.Task.cancelling + # is new in Python 3.11 + return bool((cancelling_ := getattr(task, "cancelling", None)) and cancelling_()) + + def run_callback_threadsafe( loop: AbstractEventLoop, callback: Callable[..., _T], *args: Any ) -> concurrent.futures.Future[_T]: From 90984c34a97d7b32fe3c3e7a91bab43b9f3d95ac Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 May 2023 07:42:19 -0500 Subject: [PATCH 416/822] Fix double call to options in sensor state (#93311) --- homeassistant/components/sensor/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index d0fdc8a08864..9b0a3b5e3d42 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -548,7 +548,9 @@ class SensorEntity(Entity): ) from err # Enum checks - if device_class == SensorDeviceClass.ENUM or self.options is not None: + if ( + options := self.options + ) is not None or device_class == SensorDeviceClass.ENUM: if device_class != SensorDeviceClass.ENUM: reason = "is missing the enum device class" if device_class is not None: @@ -557,7 +559,7 @@ class SensorEntity(Entity): f"Sensor {self.entity_id} is providing enum options, but {reason}" ) - if (options := self.options) and value not in options: + if options and value not in options: raise ValueError( f"Sensor {self.entity_id} provides state value '{value}', " "which is not in the list of options provided" From d47e58fb38337cf7a6d9d2ff22581bf9a8d8e732 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sun, 21 May 2023 12:50:10 +0000 Subject: [PATCH 417/822] Bump `accuweather` to version 1.0.0 (#93305) --- .../components/accuweather/__init__.py | 6 +- .../components/accuweather/manifest.json | 2 +- .../components/accuweather/weather.py | 31 ++-------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/accuweather/__init__.py | 2 +- .../fixtures/current_conditions_data.json | 1 + .../accuweather/fixtures/forecast_data.json | 60 +++++++++++++++++-- .../accuweather/test_config_flow.py | 2 +- .../accuweather/test_diagnostics.py | 2 +- tests/components/accuweather/test_init.py | 2 +- tests/components/accuweather/test_sensor.py | 2 +- tests/components/accuweather/test_weather.py | 6 +- 13 files changed, 75 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/accuweather/__init__.py b/homeassistant/components/accuweather/__init__.py index c7943d15bd0e..2a19f0d0291a 100644 --- a/homeassistant/components/accuweather/__init__.py +++ b/homeassistant/components/accuweather/__init__.py @@ -121,12 +121,12 @@ class AccuWeatherDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): async def _async_update_data(self) -> dict[str, Any]: """Update data via library.""" + forecast: list[dict[str, Any]] = [] try: async with timeout(10): current = await self.accuweather.async_get_current_conditions() - forecast = ( - await self.accuweather.async_get_forecast() if self.forecast else {} - ) + if self.forecast: + forecast = await self.accuweather.async_get_daily_forecast() except ( ApiError, ClientConnectorError, diff --git a/homeassistant/components/accuweather/manifest.json b/homeassistant/components/accuweather/manifest.json index fbf31720e13e..658b5d368d05 100644 --- a/homeassistant/components/accuweather/manifest.json +++ b/homeassistant/components/accuweather/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["accuweather"], "quality_scale": "platinum", - "requirements": ["accuweather==0.5.2"] + "requirements": ["accuweather==1.0.0"] } diff --git a/homeassistant/components/accuweather/weather.py b/homeassistant/components/accuweather/weather.py index 0ef729b9b692..6107285e3760 100644 --- a/homeassistant/components/accuweather/weather.py +++ b/homeassistant/components/accuweather/weather.py @@ -1,8 +1,7 @@ """Support for the AccuWeather service.""" from __future__ import annotations -from statistics import mean -from typing import Any, cast +from typing import cast from homeassistant.components.weather import ( ATTR_FORECAST_CONDITION, @@ -120,15 +119,10 @@ class AccuWeatherEntity( ATTR_FORECAST_TIME: utc_from_timestamp(item["EpochDate"]).isoformat(), ATTR_FORECAST_NATIVE_TEMP: item["TemperatureMax"]["Value"], ATTR_FORECAST_NATIVE_TEMP_LOW: item["TemperatureMin"]["Value"], - ATTR_FORECAST_NATIVE_PRECIPITATION: self._calc_precipitation(item), - ATTR_FORECAST_PRECIPITATION_PROBABILITY: round( - mean( - [ - item["PrecipitationProbabilityDay"], - item["PrecipitationProbabilityNight"], - ] - ) - ), + ATTR_FORECAST_NATIVE_PRECIPITATION: item["TotalLiquidDay"]["Value"], + ATTR_FORECAST_PRECIPITATION_PROBABILITY: item[ + "PrecipitationProbabilityDay" + ], ATTR_FORECAST_NATIVE_WIND_SPEED: item["WindDay"]["Speed"]["Value"], ATTR_FORECAST_WIND_BEARING: item["WindDay"]["Direction"]["Degrees"], ATTR_FORECAST_CONDITION: [ @@ -137,18 +131,3 @@ class AccuWeatherEntity( } for item in self.coordinator.data[ATTR_FORECAST] ] - - @staticmethod - def _calc_precipitation(day: dict[str, Any]) -> float: - """Return sum of the precipitation.""" - precip_sum = 0 - precip_types = ["Rain", "Snow", "Ice"] - for precip in precip_types: - precip_sum = sum( - [ - precip_sum, - day[f"{precip}Day"]["Value"], - day[f"{precip}Night"]["Value"], - ] - ) - return round(precip_sum, 1) diff --git a/requirements_all.txt b/requirements_all.txt index 07fbb1d88d4e..6aa217b00686 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -71,7 +71,7 @@ WSDiscovery==2.0.0 WazeRouteCalculator==0.14 # homeassistant.components.accuweather -accuweather==0.5.2 +accuweather==1.0.0 # homeassistant.components.adax adax==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ada42531b2e6..55573f84f006 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -61,7 +61,7 @@ WSDiscovery==2.0.0 WazeRouteCalculator==0.14 # homeassistant.components.accuweather -accuweather==0.5.2 +accuweather==1.0.0 # homeassistant.components.adax adax==0.2.0 diff --git a/tests/components/accuweather/__init__.py b/tests/components/accuweather/__init__.py index fd6441b4346a..3c7f81450c65 100644 --- a/tests/components/accuweather/__init__.py +++ b/tests/components/accuweather/__init__.py @@ -41,7 +41,7 @@ async def init_integration( "homeassistant.components.accuweather.AccuWeather.async_get_current_conditions", return_value=current, ), patch( - "homeassistant.components.accuweather.AccuWeather.async_get_forecast", + "homeassistant.components.accuweather.AccuWeather.async_get_daily_forecast", return_value=forecast, ), patch( "homeassistant.components.accuweather.AccuWeather.requests_remaining", diff --git a/tests/components/accuweather/fixtures/current_conditions_data.json b/tests/components/accuweather/fixtures/current_conditions_data.json index 020345bf8fa3..97f42dcc4110 100644 --- a/tests/components/accuweather/fixtures/current_conditions_data.json +++ b/tests/components/accuweather/fixtures/current_conditions_data.json @@ -1,4 +1,5 @@ { + "WeatherText": "Sunny", "WeatherIcon": 1, "HasPrecipitation": false, "PrecipitationType": null, diff --git a/tests/components/accuweather/fixtures/forecast_data.json b/tests/components/accuweather/fixtures/forecast_data.json index 1b432fbd0757..a7d57af113a4 100644 --- a/tests/components/accuweather/fixtures/forecast_data.json +++ b/tests/components/accuweather/fixtures/forecast_data.json @@ -137,6 +137,11 @@ "HoursOfSnowDay": 0.0, "HoursOfIceDay": 0.0, "CloudCoverDay": 58, + "SolarIrradianceDay": { + "Value": 7447.1, + "Unit": "W/m\u00b2", + "UnitType": 33 + }, "IconNight": 41, "IconPhraseNight": "Partly cloudy w/ t-storms", "HasPrecipitationNight": true, @@ -197,7 +202,12 @@ "HoursOfRainNight": 1.0, "HoursOfSnowNight": 0.0, "HoursOfIceNight": 0.0, - "CloudCoverNight": 65 + "CloudCoverNight": 65, + "SolarIrradianceNight": { + "Value": 271.6, + "Unit": "W/m\u00b2", + "UnitType": 33 + } }, { "Date": "2020-07-27T07:00:00+02:00", @@ -335,6 +345,11 @@ "HoursOfSnowDay": 0.0, "HoursOfIceDay": 0.0, "CloudCoverDay": 52, + "SolarIrradianceDay": { + "Value": 7447.1, + "Unit": "W/m\u00b2", + "UnitType": 33 + }, "IconNight": 36, "IconPhraseNight": "Intermittent clouds", "HasPrecipitationNight": false, @@ -393,7 +408,12 @@ "HoursOfRainNight": 0.0, "HoursOfSnowNight": 0.0, "HoursOfIceNight": 0.0, - "CloudCoverNight": 63 + "CloudCoverNight": 63, + "SolarIrradianceNight": { + "Value": 271.6, + "Unit": "W/m\u00b2", + "UnitType": 33 + } }, { "Date": "2020-07-28T07:00:00+02:00", @@ -531,6 +551,11 @@ "HoursOfSnowDay": 0.0, "HoursOfIceDay": 0.0, "CloudCoverDay": 65, + "SolarIrradianceDay": { + "Value": 7447.1, + "Unit": "W/m\u00b2", + "UnitType": 33 + }, "IconNight": 36, "IconPhraseNight": "Intermittent clouds", "HasPrecipitationNight": false, @@ -589,7 +614,12 @@ "HoursOfRainNight": 0.0, "HoursOfSnowNight": 0.0, "HoursOfIceNight": 0.0, - "CloudCoverNight": 53 + "CloudCoverNight": 53, + "SolarIrradianceNight": { + "Value": 271.6, + "Unit": "W/m\u00b2", + "UnitType": 33 + } }, { "Date": "2020-07-29T07:00:00+02:00", @@ -727,6 +757,11 @@ "HoursOfSnowDay": 0.0, "HoursOfIceDay": 0.0, "CloudCoverDay": 45, + "SolarIrradianceDay": { + "Value": 7447.1, + "Unit": "W/m\u00b2", + "UnitType": 33 + }, "IconNight": 34, "IconPhraseNight": "Mostly clear", "HasPrecipitationNight": false, @@ -785,7 +820,12 @@ "HoursOfRainNight": 0.0, "HoursOfSnowNight": 0.0, "HoursOfIceNight": 0.0, - "CloudCoverNight": 27 + "CloudCoverNight": 27, + "SolarIrradianceNight": { + "Value": 7447.1, + "Unit": "W/m\u00b2", + "UnitType": 33 + } }, { "Date": "2020-07-30T07:00:00+02:00", @@ -923,6 +963,11 @@ "HoursOfSnowDay": 0.0, "HoursOfIceDay": 0.0, "CloudCoverDay": 50, + "SolarIrradianceDay": { + "Value": 7447.1, + "Unit": "W/m\u00b2", + "UnitType": 33 + }, "IconNight": 34, "IconPhraseNight": "Mostly clear", "HasPrecipitationNight": false, @@ -981,6 +1026,11 @@ "HoursOfRainNight": 0.0, "HoursOfSnowNight": 0.0, "HoursOfIceNight": 0.0, - "CloudCoverNight": 13 + "CloudCoverNight": 13, + "SolarIrradianceNight": { + "Value": 276.1, + "Unit": "W/m\u00b2", + "UnitType": 33 + } } ] diff --git a/tests/components/accuweather/test_config_flow.py b/tests/components/accuweather/test_config_flow.py index 4d8d83a2a2f2..35b6e095c0f8 100644 --- a/tests/components/accuweather/test_config_flow.py +++ b/tests/components/accuweather/test_config_flow.py @@ -156,7 +156,7 @@ async def test_options_flow(hass: HomeAssistant) -> None: "accuweather/current_conditions_data.json" ), ), patch( - "homeassistant.components.accuweather.AccuWeather.async_get_forecast" + "homeassistant.components.accuweather.AccuWeather.async_get_daily_forecast" ), patch( "homeassistant.components.accuweather.AccuWeather.requests_remaining", new_callable=PropertyMock, diff --git a/tests/components/accuweather/test_diagnostics.py b/tests/components/accuweather/test_diagnostics.py index 767bbd9953a4..98be70d9ec67 100644 --- a/tests/components/accuweather/test_diagnostics.py +++ b/tests/components/accuweather/test_diagnostics.py @@ -19,7 +19,7 @@ async def test_entry_diagnostics( "current_conditions_data.json", "accuweather" ) - coordinator_data["forecast"] = {} + coordinator_data["forecast"] = [] result = await get_diagnostics_for_config_entry(hass, hass_client, entry) diff --git a/tests/components/accuweather/test_init.py b/tests/components/accuweather/test_init.py index 7123d5ef8177..c7f79b487b5e 100644 --- a/tests/components/accuweather/test_init.py +++ b/tests/components/accuweather/test_init.py @@ -104,7 +104,7 @@ async def test_update_interval_forecast(hass: HomeAssistant) -> None: "homeassistant.components.accuweather.AccuWeather.async_get_current_conditions", return_value=current, ) as mock_current, patch( - "homeassistant.components.accuweather.AccuWeather.async_get_forecast", + "homeassistant.components.accuweather.AccuWeather.async_get_daily_forecast", return_value=forecast, ) as mock_forecast: assert mock_current.call_count == 0 diff --git a/tests/components/accuweather/test_sensor.py b/tests/components/accuweather/test_sensor.py index 37e58504efeb..f27aa158e0e4 100644 --- a/tests/components/accuweather/test_sensor.py +++ b/tests/components/accuweather/test_sensor.py @@ -717,7 +717,7 @@ async def test_manual_update_entity(hass: HomeAssistant) -> None: "homeassistant.components.accuweather.AccuWeather.async_get_current_conditions", return_value=current, ) as mock_current, patch( - "homeassistant.components.accuweather.AccuWeather.async_get_forecast", + "homeassistant.components.accuweather.AccuWeather.async_get_daily_forecast", return_value=forecast, ) as mock_forecast, patch( "homeassistant.components.accuweather.AccuWeather.requests_remaining", diff --git a/tests/components/accuweather/test_weather.py b/tests/components/accuweather/test_weather.py index ab4faf73c81f..de1703128ddc 100644 --- a/tests/components/accuweather/test_weather.py +++ b/tests/components/accuweather/test_weather.py @@ -74,8 +74,8 @@ async def test_weather_with_forecast(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION forecast = state.attributes.get(ATTR_FORECAST)[0] assert forecast.get(ATTR_FORECAST_CONDITION) == "lightning-rainy" - assert forecast.get(ATTR_FORECAST_PRECIPITATION) == 4.8 - assert forecast.get(ATTR_FORECAST_PRECIPITATION_PROBABILITY) == 58 + assert forecast.get(ATTR_FORECAST_PRECIPITATION) == 2.5 + assert forecast.get(ATTR_FORECAST_PRECIPITATION_PROBABILITY) == 60 assert forecast.get(ATTR_FORECAST_TEMP) == 29.5 assert forecast.get(ATTR_FORECAST_TEMP_LOW) == 15.4 assert forecast.get(ATTR_FORECAST_TIME) == "2020-07-26T05:00:00+00:00" @@ -141,7 +141,7 @@ async def test_manual_update_entity(hass: HomeAssistant) -> None: "homeassistant.components.accuweather.AccuWeather.async_get_current_conditions", return_value=current, ) as mock_current, patch( - "homeassistant.components.accuweather.AccuWeather.async_get_forecast", + "homeassistant.components.accuweather.AccuWeather.async_get_daily_forecast", return_value=forecast, ) as mock_forecast, patch( "homeassistant.components.accuweather.AccuWeather.requests_remaining", From 8a0f3a8c882b62d7a2d91feab583e147374df3a3 Mon Sep 17 00:00:00 2001 From: henryptung Date: Sun, 21 May 2023 05:53:06 -0700 Subject: [PATCH 418/822] Fix Broadlink discovery for new RM Mini3 (again) (#93158) --- homeassistant/components/broadlink/manifest.json | 9 +++++++++ homeassistant/generated/dhcp.py | 12 ++++++++++++ 2 files changed, 21 insertions(+) diff --git a/homeassistant/components/broadlink/manifest.json b/homeassistant/components/broadlink/manifest.json index cb4e145d11e9..5778520e530a 100644 --- a/homeassistant/components/broadlink/manifest.json +++ b/homeassistant/components/broadlink/manifest.json @@ -21,6 +21,15 @@ }, { "macaddress": "C8F742*" + }, + { + "macaddress": "E81656*" + }, + { + "macaddress": "E87072*" + }, + { + "macaddress": "EC0BAE*" } ], "documentation": "https://www.home-assistant.io/integrations/broadlink", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index adcc32fe8d92..ce5536e62a44 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -97,6 +97,18 @@ DHCP: list[dict[str, str | bool]] = [ "domain": "broadlink", "macaddress": "C8F742*", }, + { + "domain": "broadlink", + "macaddress": "E81656*", + }, + { + "domain": "broadlink", + "macaddress": "E87072*", + }, + { + "domain": "broadlink", + "macaddress": "EC0BAE*", + }, { "domain": "dlink", "hostname": "dsp-w215", From 5821a563c7e2002bc7d4c67b165d389ba7e6adfa Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sun, 21 May 2023 15:20:50 +0200 Subject: [PATCH 419/822] Bump motionblinds to 0.6.18 (#93300) --- homeassistant/components/motion_blinds/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/motion_blinds/manifest.json b/homeassistant/components/motion_blinds/manifest.json index 0ca982ada82a..cc31ff42edf7 100644 --- a/homeassistant/components/motion_blinds/manifest.json +++ b/homeassistant/components/motion_blinds/manifest.json @@ -21,5 +21,5 @@ "documentation": "https://www.home-assistant.io/integrations/motion_blinds", "iot_class": "local_push", "loggers": ["motionblinds"], - "requirements": ["motionblinds==0.6.17"] + "requirements": ["motionblinds==0.6.18"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6aa217b00686..2445c2752f27 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1154,7 +1154,7 @@ moehlenhoff-alpha2==1.3.0 mopeka_iot_ble==0.4.1 # homeassistant.components.motion_blinds -motionblinds==0.6.17 +motionblinds==0.6.18 # homeassistant.components.motioneye motioneye-client==0.3.14 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 55573f84f006..7f01508d3d21 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -874,7 +874,7 @@ moehlenhoff-alpha2==1.3.0 mopeka_iot_ble==0.4.1 # homeassistant.components.motion_blinds -motionblinds==0.6.17 +motionblinds==0.6.18 # homeassistant.components.motioneye motioneye-client==0.3.14 From 40c74ad5b77cb0dbb0179bc5f8a56ef7a099e800 Mon Sep 17 00:00:00 2001 From: Ernst Klamer Date: Sun, 21 May 2023 16:01:09 +0200 Subject: [PATCH 420/822] Add support for sleepy BTHome devices (#92991) Co-authored-by: J. Nick Koston --- homeassistant/components/bthome/__init__.py | 28 ++-- .../components/bthome/binary_sensor.py | 22 ++- .../components/bthome/coordinator.py | 42 ++++++ homeassistant/components/bthome/manifest.json | 2 +- homeassistant/components/bthome/models.py | 11 -- homeassistant/components/bthome/sensor.py | 22 ++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/bthome/test_binary_sensor.py | 131 ++++++++++++++++- tests/components/bthome/test_sensor.py | 132 +++++++++++++++++- 10 files changed, 345 insertions(+), 49 deletions(-) create mode 100644 homeassistant/components/bthome/coordinator.py delete mode 100644 homeassistant/components/bthome/models.py diff --git a/homeassistant/components/bthome/__init__.py b/homeassistant/components/bthome/__init__.py index 1255def44cb2..3e2e17a9a211 100644 --- a/homeassistant/components/bthome/__init__.py +++ b/homeassistant/components/bthome/__init__.py @@ -11,9 +11,6 @@ from homeassistant.components.bluetooth import ( BluetoothScanningMode, BluetoothServiceInfoBleak, ) -from homeassistant.components.bluetooth.passive_update_processor import ( - PassiveBluetoothProcessorCoordinator, -) from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -26,7 +23,7 @@ from .const import ( DOMAIN, BTHomeBleEvent, ) -from .models import BTHomeData +from .coordinator import BTHomePassiveBluetoothProcessorCoordinator PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -42,7 +39,10 @@ def process_service_info( ) -> SensorUpdate: """Process a BluetoothServiceInfoBleak, running side effects and returning sensor data.""" update = data.update(service_info) - domain_data: BTHomeData = hass.data[DOMAIN][entry.entry_id] + coordinator: BTHomePassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ + entry.entry_id + ] + discovered_device_classes = coordinator.discovered_device_classes if update.events: address = service_info.device.address for device_key, event in update.events.items(): @@ -59,16 +59,12 @@ def process_service_info( event_class = event.device_key.key event_type = event.event_type - if event_class not in domain_data.discovered_event_classes: - domain_data.discovered_event_classes.add(event_class) + if event_class not in discovered_device_classes: + discovered_device_classes.add(event_class) hass.config_entries.async_update_entry( entry, data=entry.data - | { - CONF_DISCOVERED_EVENT_CLASSES: list( - domain_data.discovered_event_classes - ) - }, + | {CONF_DISCOVERED_EVENT_CLASSES: list(discovered_device_classes)}, ) hass.bus.async_fire( @@ -104,7 +100,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: device_registry = async_get(hass) coordinator = hass.data.setdefault(DOMAIN, {})[ entry.entry_id - ] = PassiveBluetoothProcessorCoordinator( + ] = BTHomePassiveBluetoothProcessorCoordinator( hass, _LOGGER, address=address, @@ -112,11 +108,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: update_method=lambda service_info: process_service_info( hass, entry, data, service_info, device_registry ), + device_data=data, + discovered_device_classes=set( + entry.data.get(CONF_DISCOVERED_EVENT_CLASSES, []) + ), connectable=False, ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - domain_data = BTHomeData(set(entry.data.get(CONF_DISCOVERED_EVENT_CLASSES, []))) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = domain_data entry.async_on_unload( coordinator.async_start() diff --git a/homeassistant/components/bthome/binary_sensor.py b/homeassistant/components/bthome/binary_sensor.py index ad36fe3644ed..d9d24e950071 100644 --- a/homeassistant/components/bthome/binary_sensor.py +++ b/homeassistant/components/bthome/binary_sensor.py @@ -13,9 +13,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.components.bluetooth.passive_update_processor import ( - PassiveBluetoothDataProcessor, PassiveBluetoothDataUpdate, - PassiveBluetoothProcessorCoordinator, PassiveBluetoothProcessorEntity, ) from homeassistant.core import HomeAssistant @@ -23,6 +21,10 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info from .const import DOMAIN +from .coordinator import ( + BTHomePassiveBluetoothDataProcessor, + BTHomePassiveBluetoothProcessorCoordinator, +) from .device import device_key_to_bluetooth_entity_key BINARY_SENSOR_DESCRIPTIONS = { @@ -173,10 +175,12 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the BTHome BLE binary sensors.""" - coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ + coordinator: BTHomePassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ entry.entry_id ] - processor = PassiveBluetoothDataProcessor(sensor_update_to_bluetooth_data_update) + processor = BTHomePassiveBluetoothDataProcessor( + sensor_update_to_bluetooth_data_update + ) entry.async_on_unload( processor.async_add_entities_listener( BTHomeBluetoothBinarySensorEntity, async_add_entities @@ -186,7 +190,7 @@ async def async_setup_entry( class BTHomeBluetoothBinarySensorEntity( - PassiveBluetoothProcessorEntity[PassiveBluetoothDataProcessor[bool | None]], + PassiveBluetoothProcessorEntity[BTHomePassiveBluetoothDataProcessor], BinarySensorEntity, ): """Representation of a BTHome binary sensor.""" @@ -195,3 +199,11 @@ class BTHomeBluetoothBinarySensorEntity( def is_on(self) -> bool | None: """Return the native value.""" return self.processor.entity_data.get(self.entity_key) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + coordinator: BTHomePassiveBluetoothProcessorCoordinator = ( + self.processor.coordinator + ) + return coordinator.device_data.sleepy_device or super().available diff --git a/homeassistant/components/bthome/coordinator.py b/homeassistant/components/bthome/coordinator.py new file mode 100644 index 000000000000..dafa932a73e9 --- /dev/null +++ b/homeassistant/components/bthome/coordinator.py @@ -0,0 +1,42 @@ +"""The BTHome Bluetooth integration.""" +from collections.abc import Callable +from logging import Logger +from typing import Any + +from bthome_ble import BTHomeBluetoothDeviceData + +from homeassistant.components.bluetooth import ( + BluetoothScanningMode, + BluetoothServiceInfoBleak, +) +from homeassistant.components.bluetooth.passive_update_processor import ( + PassiveBluetoothDataProcessor, + PassiveBluetoothProcessorCoordinator, +) +from homeassistant.core import HomeAssistant + + +class BTHomePassiveBluetoothProcessorCoordinator(PassiveBluetoothProcessorCoordinator): + """Define a BTHome Bluetooth Passive Update Processor Coordinator.""" + + def __init__( + self, + hass: HomeAssistant, + logger: Logger, + address: str, + mode: BluetoothScanningMode, + update_method: Callable[[BluetoothServiceInfoBleak], Any], + device_data: BTHomeBluetoothDeviceData, + discovered_device_classes: set[str], + connectable: bool = False, + ) -> None: + """Initialize the BTHome Bluetooth Passive Update Processor Coordinator.""" + super().__init__(hass, logger, address, mode, update_method, connectable) + self.discovered_device_classes = discovered_device_classes + self.device_data = device_data + + +class BTHomePassiveBluetoothDataProcessor(PassiveBluetoothDataProcessor): + """Define a BTHome Bluetooth Passive Update Data Processor.""" + + coordinator: BTHomePassiveBluetoothProcessorCoordinator diff --git a/homeassistant/components/bthome/manifest.json b/homeassistant/components/bthome/manifest.json index 87a84e5fab00..ef3d9bc002d1 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==2.9.0"] + "requirements": ["bthome-ble==2.11.3"] } diff --git a/homeassistant/components/bthome/models.py b/homeassistant/components/bthome/models.py deleted file mode 100644 index 558f19c77423..000000000000 --- a/homeassistant/components/bthome/models.py +++ /dev/null @@ -1,11 +0,0 @@ -"""The bthome integration models.""" -from __future__ import annotations - -from dataclasses import dataclass - - -@dataclass -class BTHomeData: - """Data for the bthome integration.""" - - discovered_event_classes: set[str] diff --git a/homeassistant/components/bthome/sensor.py b/homeassistant/components/bthome/sensor.py index 9b5def30054c..f8693c5fb348 100644 --- a/homeassistant/components/bthome/sensor.py +++ b/homeassistant/components/bthome/sensor.py @@ -5,9 +5,7 @@ from bthome_ble import SensorDeviceClass as BTHomeSensorDeviceClass, SensorUpdat from homeassistant import config_entries from homeassistant.components.bluetooth.passive_update_processor import ( - PassiveBluetoothDataProcessor, PassiveBluetoothDataUpdate, - PassiveBluetoothProcessorCoordinator, PassiveBluetoothProcessorEntity, ) from homeassistant.components.sensor import ( @@ -42,6 +40,10 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info from .const import DOMAIN +from .coordinator import ( + BTHomePassiveBluetoothDataProcessor, + BTHomePassiveBluetoothProcessorCoordinator, +) from .device import device_key_to_bluetooth_entity_key SENSOR_DESCRIPTIONS = { @@ -343,10 +345,12 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the BTHome BLE sensors.""" - coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ + coordinator: BTHomePassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ entry.entry_id ] - processor = PassiveBluetoothDataProcessor(sensor_update_to_bluetooth_data_update) + processor = BTHomePassiveBluetoothDataProcessor( + sensor_update_to_bluetooth_data_update + ) entry.async_on_unload( processor.async_add_entities_listener( BTHomeBluetoothSensorEntity, async_add_entities @@ -356,7 +360,7 @@ async def async_setup_entry( class BTHomeBluetoothSensorEntity( - PassiveBluetoothProcessorEntity[PassiveBluetoothDataProcessor[float | int | None]], + PassiveBluetoothProcessorEntity[BTHomePassiveBluetoothDataProcessor], SensorEntity, ): """Representation of a BTHome BLE sensor.""" @@ -365,3 +369,11 @@ class BTHomeBluetoothSensorEntity( def native_value(self) -> int | float | None: """Return the native value.""" return self.processor.entity_data.get(self.entity_key) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + coordinator: BTHomePassiveBluetoothProcessorCoordinator = ( + self.processor.coordinator + ) + return coordinator.device_data.sleepy_device or super().available diff --git a/requirements_all.txt b/requirements_all.txt index 2445c2752f27..4094cc2acc56 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -499,7 +499,7 @@ brunt==1.2.0 bt_proximity==0.2.1 # homeassistant.components.bthome -bthome-ble==2.9.0 +bthome-ble==2.11.3 # homeassistant.components.bt_home_hub_5 bthomehub5-devicelist==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7f01508d3d21..05954c0f9e6a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -412,7 +412,7 @@ brottsplatskartan==0.0.1 brunt==1.2.0 # homeassistant.components.bthome -bthome-ble==2.9.0 +bthome-ble==2.11.3 # homeassistant.components.buienradar buienradar==1.0.5 diff --git a/tests/components/bthome/test_binary_sensor.py b/tests/components/bthome/test_binary_sensor.py index 4905a7ef3fee..cc5ad13dc80b 100644 --- a/tests/components/bthome/test_binary_sensor.py +++ b/tests/components/bthome/test_binary_sensor.py @@ -1,16 +1,31 @@ """Test BTHome binary sensors.""" +from datetime import timedelta import logging +import time +from unittest.mock import patch import pytest +from homeassistant.components.bluetooth import ( + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, +) from homeassistant.components.bthome.const import DOMAIN -from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_OFF, STATE_ON +from homeassistant.const import ( + ATTR_FRIENDLY_NAME, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, +) from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util from . import make_bthome_v1_adv, make_bthome_v2_adv -from tests.common import MockConfigEntry -from tests.components.bluetooth import inject_bluetooth_service_info +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.components.bluetooth import ( + inject_bluetooth_service_info, + patch_all_discovered_devices, +) _LOGGER = logging.getLogger(__name__) @@ -123,7 +138,7 @@ async def test_v1_binary_sensors( "A4:C1:38:8D:18:B2", make_bthome_v2_adv( "A4:C1:38:8D:18:B2", - b"\x40\x11\x00", + b"\x44\x11\x00", ), None, [ @@ -185,3 +200,111 @@ async def test_v2_binary_sensors( assert binary_sensor_attr[ATTR_FRIENDLY_NAME] == meas["friendly_name"] assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() + + +async def test_unavailable(hass: HomeAssistant) -> None: + """Test normal device goes to unavailable after 60 minutes.""" + start_monotonic = time.monotonic() + + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="A4:C1:38:8D:18:B2", + data={}, + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 0 + + inject_bluetooth_service_info( + hass, + make_bthome_v2_adv( + "A4:C1:38:8D:18:B2", + b"\x40\x11\x01", + ), + ) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 + + opening_sensor = hass.states.get("binary_sensor.test_device_18b2_opening") + + assert opening_sensor.state == STATE_ON + + # Fastforward time without BLE advertisements + monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 + + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=monotonic_now, + ), patch_all_discovered_devices([]): + async_fire_time_changed( + hass, + dt_util.utcnow() + + timedelta(seconds=FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1), + ) + await hass.async_block_till_done() + + opening_sensor = hass.states.get("binary_sensor.test_device_18b2_opening") + + # Normal devices should go to unavailable + assert opening_sensor.state == STATE_UNAVAILABLE + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + +async def test_sleepy_device(hass: HomeAssistant) -> None: + """Test sleepy device does not go to unavailable after 60 minutes.""" + start_monotonic = time.monotonic() + + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="A4:C1:38:8D:18:B2", + data={}, + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 0 + + inject_bluetooth_service_info( + hass, + make_bthome_v2_adv( + "A4:C1:38:8D:18:B2", + b"\x44\x11\x01", + ), + ) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 + + opening_sensor = hass.states.get("binary_sensor.test_device_18b2_opening") + + assert opening_sensor.state == STATE_ON + + # Fastforward time without BLE advertisements + monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 + + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=monotonic_now, + ), patch_all_discovered_devices([]): + async_fire_time_changed( + hass, + dt_util.utcnow() + + timedelta(seconds=FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1), + ) + await hass.async_block_till_done() + + opening_sensor = hass.states.get("binary_sensor.test_device_18b2_opening") + + # Sleepy devices should keep their state over time + assert opening_sensor.state == STATE_ON + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/bthome/test_sensor.py b/tests/components/bthome/test_sensor.py index 7893ad3cb440..7aafe7ba7a9d 100644 --- a/tests/components/bthome/test_sensor.py +++ b/tests/components/bthome/test_sensor.py @@ -1,17 +1,31 @@ """Test the BTHome sensors.""" +from datetime import timedelta import logging +import time +from unittest.mock import patch import pytest +from homeassistant.components.bluetooth import ( + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, +) from homeassistant.components.bthome.const import DOMAIN from homeassistant.components.sensor import ATTR_STATE_CLASS -from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT +from homeassistant.const import ( + ATTR_FRIENDLY_NAME, + ATTR_UNIT_OF_MEASUREMENT, + STATE_UNAVAILABLE, +) from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util from . import make_bthome_v1_adv, make_bthome_v2_adv, make_encrypted_bthome_v1_adv -from tests.common import MockConfigEntry -from tests.components.bluetooth import inject_bluetooth_service_info +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.components.bluetooth import ( + inject_bluetooth_service_info, + patch_all_discovered_devices, +) _LOGGER = logging.getLogger(__name__) @@ -518,7 +532,7 @@ async def test_v1_sensors( "A4:C1:38:8D:18:B2", make_bthome_v2_adv( "A4:C1:38:8D:18:B2", - b"\x40\x09\x60", + b"\x44\x09\x60", ), None, [ @@ -1008,9 +1022,7 @@ async def test_v2_sensors( assert len(hass.states.async_all()) == len(result) for meas in result: - _LOGGER.error(meas) sensor = hass.states.get(meas["sensor_entity"]) - _LOGGER.error(hass.states) sensor_attr = sensor.attributes assert sensor.state == meas["expected_state"] assert sensor_attr[ATTR_FRIENDLY_NAME] == meas["friendly_name"] @@ -1020,3 +1032,111 @@ async def test_v2_sensors( assert sensor_attr[ATTR_STATE_CLASS] == meas["state_class"] assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() + + +async def test_unavailable(hass: HomeAssistant) -> None: + """Test normal device goes to unavailable after 60 minutes.""" + start_monotonic = time.monotonic() + + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="A4:C1:38:8D:18:B2", + data={}, + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 0 + + inject_bluetooth_service_info( + hass, + make_bthome_v2_adv( + "A4:C1:38:8D:18:B2", + b"\x40\x04\x13\x8a\x01", + ), + ) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 + + pressure_sensor = hass.states.get("sensor.test_device_18b2_pressure") + + assert pressure_sensor.state == "1008.83" + + # Fastforward time without BLE advertisements + monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 + + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=monotonic_now, + ), patch_all_discovered_devices([]): + async_fire_time_changed( + hass, + dt_util.utcnow() + + timedelta(seconds=FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1), + ) + await hass.async_block_till_done() + + pressure_sensor = hass.states.get("sensor.test_device_18b2_pressure") + + # Normal devices should go to unavailable + assert pressure_sensor.state == STATE_UNAVAILABLE + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + +async def test_sleepy_device(hass: HomeAssistant) -> None: + """Test sleepy device does not go to unavailable after 60 minutes.""" + start_monotonic = time.monotonic() + + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="A4:C1:38:8D:18:B2", + data={}, + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 0 + + inject_bluetooth_service_info( + hass, + make_bthome_v2_adv( + "A4:C1:38:8D:18:B2", + b"\x44\x04\x13\x8a\x01", + ), + ) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 + + pressure_sensor = hass.states.get("sensor.test_device_18b2_pressure") + + assert pressure_sensor.state == "1008.83" + + # Fastforward time without BLE advertisements + monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 + + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=monotonic_now, + ), patch_all_discovered_devices([]): + async_fire_time_changed( + hass, + dt_util.utcnow() + + timedelta(seconds=FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1), + ) + await hass.async_block_till_done() + + pressure_sensor = hass.states.get("sensor.test_device_18b2_pressure") + + # Sleepy devices should keep their state over time + assert pressure_sensor.state == "1008.83" + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() From ab0d35df927958786342b3c28bd54db66a78746a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 May 2023 09:12:19 -0500 Subject: [PATCH 421/822] Add zeroconf support to roomba (#93309) --- .../components/roomba/config_flow.py | 28 ++++++--- homeassistant/components/roomba/manifest.json | 12 +++- homeassistant/generated/zeroconf.py | 10 ++++ tests/components/roomba/test_config_flow.py | 60 ++++++++++++++----- 4 files changed, 88 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/roomba/config_flow.py b/homeassistant/components/roomba/config_flow.py index 0a1c51ca38ce..e4fb45865a2a 100644 --- a/homeassistant/components/roomba/config_flow.py +++ b/homeassistant/components/roomba/config_flow.py @@ -10,7 +10,7 @@ from roombapy.getpassword import RoombaPassword import voluptuous as vol from homeassistant import config_entries, core -from homeassistant.components import dhcp +from homeassistant.components import dhcp, zeroconf from homeassistant.const import CONF_DELAY, CONF_HOST, CONF_NAME, CONF_PASSWORD from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult @@ -85,17 +85,31 @@ class RoombaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Get the options flow for this handler.""" return OptionsFlowHandler(config_entry) + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: + """Handle zeroconf discovery.""" + return await self._async_step_discovery( + discovery_info.host, discovery_info.hostname.lower().rstrip(".local.") + ) + async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: """Handle dhcp discovery.""" - self._async_abort_entries_match({CONF_HOST: discovery_info.ip}) + return await self._async_step_discovery( + discovery_info.ip, discovery_info.hostname + ) - if not discovery_info.hostname.startswith(("irobot-", "roomba-")): + async def _async_step_discovery(self, ip_address: str, hostname: str) -> FlowResult: + """Handle any discovery.""" + self._async_abort_entries_match({CONF_HOST: ip_address}) + + if not hostname.startswith(("irobot-", "roomba-")): return self.async_abort(reason="not_irobot_device") - self.host = discovery_info.ip - self.blid = _async_blid_from_hostname(discovery_info.hostname) + self.host = ip_address + self.blid = _async_blid_from_hostname(hostname) await self.async_set_unique_id(self.blid) - self._abort_if_unique_id_configured(updates={CONF_HOST: self.host}) + self._abort_if_unique_id_configured(updates={CONF_HOST: ip_address}) # Because the hostname is so long some sources may # truncate the hostname since it will be longer than @@ -103,7 +117,7 @@ class RoombaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # going for a longer hostname we abort so the user # does not see two flows if discovery fails. for progress in self._async_in_progress(): - flow_unique_id = progress["context"]["unique_id"] + flow_unique_id: str = progress["context"]["unique_id"] if flow_unique_id.startswith(self.blid): return self.async_abort(reason="short_blid") if self.blid.startswith(flow_unique_id): diff --git a/homeassistant/components/roomba/manifest.json b/homeassistant/components/roomba/manifest.json index 7b437a4f8c48..9e18465922a4 100644 --- a/homeassistant/components/roomba/manifest.json +++ b/homeassistant/components/roomba/manifest.json @@ -24,5 +24,15 @@ "documentation": "https://www.home-assistant.io/integrations/roomba", "iot_class": "local_push", "loggers": ["paho_mqtt", "roombapy"], - "requirements": ["roombapy==1.6.8"] + "requirements": ["roombapy==1.6.8"], + "zeroconf": [ + { + "type": "_amzn-alexa._tcp.local.", + "name": "irobot-*" + }, + { + "type": "_amzn-alexa._tcp.local.", + "name": "roomba-*" + } + ] } diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 1771d9d63bfd..93ccb404ae45 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -279,6 +279,16 @@ ZEROCONF = { "domain": "apple_tv", }, ], + "_amzn-alexa._tcp.local.": [ + { + "domain": "roomba", + "name": "irobot-*", + }, + { + "domain": "roomba", + "name": "roomba-*", + }, + ], "_androidtvremote2._tcp.local.": [ { "domain": "androidtv_remote", diff --git a/tests/components/roomba/test_config_flow.py b/tests/components/roomba/test_config_flow.py index b0ab7a492949..0b39c34d3b8c 100644 --- a/tests/components/roomba/test_config_flow.py +++ b/tests/components/roomba/test_config_flow.py @@ -5,7 +5,7 @@ import pytest from roombapy import RoombaConnectionError, RoombaInfo from homeassistant import config_entries, data_entry_flow -from homeassistant.components import dhcp +from homeassistant.components import dhcp, zeroconf from homeassistant.components.roomba import config_flow from homeassistant.components.roomba.const import CONF_BLID, CONF_CONTINUOUS, DOMAIN from homeassistant.const import CONF_DELAY, CONF_HOST, CONF_PASSWORD @@ -16,16 +16,46 @@ from tests.common import MockConfigEntry MOCK_IP = "1.2.3.4" VALID_CONFIG = {CONF_HOST: MOCK_IP, CONF_BLID: "BLID", CONF_PASSWORD: "password"} -DHCP_DISCOVERY_DEVICES = [ - dhcp.DhcpServiceInfo( - ip=MOCK_IP, - macaddress="50:14:79:DD:EE:FF", - hostname="irobot-blid", +DISCOVERY_DEVICES = [ + ( + config_entries.SOURCE_DHCP, + dhcp.DhcpServiceInfo( + ip=MOCK_IP, + macaddress="50:14:79:DD:EE:FF", + hostname="irobot-blid", + ), ), - dhcp.DhcpServiceInfo( - ip=MOCK_IP, - macaddress="80:A5:89:DD:EE:FF", - hostname="roomba-blid", + ( + config_entries.SOURCE_DHCP, + dhcp.DhcpServiceInfo( + ip=MOCK_IP, + macaddress="80:A5:89:DD:EE:FF", + hostname="roomba-blid", + ), + ), + ( + config_entries.SOURCE_ZEROCONF, + zeroconf.ZeroconfServiceInfo( + host=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, + hostname="roomba-blid.local.", + name="roomba-blid._amzn-alexa._tcp.local.", + type="_amzn-alexa._tcp.local.", + port=443, + properties={}, + addresses=[MOCK_IP], + ), ), ] @@ -625,9 +655,10 @@ async def test_form_user_discovery_and_password_fetch_gets_connection_refused( assert len(mock_setup_entry.mock_calls) == 1 -@pytest.mark.parametrize("discovery_data", DHCP_DISCOVERY_DEVICES) +@pytest.mark.parametrize("discovery_data", DISCOVERY_DEVICES) async def test_dhcp_discovery_and_roomba_discovery_finds( - hass: HomeAssistant, discovery_data + hass: HomeAssistant, + discovery_data: tuple[str, dhcp.DhcpServiceInfo | zeroconf.ZeroconfServiceInfo], ) -> None: """Test we can process the discovery from dhcp and roomba discovery matches the device.""" @@ -635,14 +666,15 @@ async def test_dhcp_discovery_and_roomba_discovery_finds( roomba_connected=True, master_state={"state": {"reported": {"name": "myroomba"}}}, ) + source, discovery = discovery_data with patch( "homeassistant.components.roomba.config_flow.RoombaDiscovery", _mocked_discovery ): result = await hass.config_entries.flow.async_init( DOMAIN, - context={"source": config_entries.SOURCE_DHCP}, - data=discovery_data, + context={"source": source}, + data=discovery, ) await hass.async_block_till_done() From e27554f7a6994f35d6da2144070d9ead56f0fbaf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 May 2023 13:36:03 -0500 Subject: [PATCH 422/822] Improve http handler performance (#93324) --- .../components/emulated_hue/__init__.py | 20 +++---- homeassistant/components/http/__init__.py | 2 +- homeassistant/components/http/view.py | 59 ++++++++++--------- tests/components/emulated_hue/test_hue_api.py | 14 ++--- tests/components/http/test_ban.py | 8 ++- tests/components/http/test_data_validator.py | 2 +- tests/components/http/test_view.py | 35 ++++++++--- 7 files changed, 81 insertions(+), 59 deletions(-) diff --git a/homeassistant/components/emulated_hue/__init__.py b/homeassistant/components/emulated_hue/__init__.py index 86102242b314..1ba93da716c5 100644 --- a/homeassistant/components/emulated_hue/__init__.py +++ b/homeassistant/components/emulated_hue/__init__.py @@ -138,16 +138,16 @@ async def async_setup(hass: HomeAssistant, yaml_config: ConfigType) -> bool: app._on_startup.freeze() await app.startup() - DescriptionXmlView(config).register(app, app.router) - HueUsernameView().register(app, app.router) - HueConfigView(config).register(app, app.router) - HueUnauthorizedUser().register(app, app.router) - HueAllLightsStateView(config).register(app, app.router) - HueOneLightStateView(config).register(app, app.router) - HueOneLightChangeView(config).register(app, app.router) - HueAllGroupsStateView(config).register(app, app.router) - HueGroupView(config).register(app, app.router) - HueFullStateView(config).register(app, app.router) + DescriptionXmlView(config).register(hass, app, app.router) + HueUsernameView().register(hass, app, app.router) + HueConfigView(config).register(hass, app, app.router) + HueUnauthorizedUser().register(hass, app, app.router) + HueAllLightsStateView(config).register(hass, app, app.router) + HueOneLightStateView(config).register(hass, app, app.router) + HueOneLightChangeView(config).register(hass, app, app.router) + HueAllGroupsStateView(config).register(hass, app, app.router) + HueGroupView(config).register(hass, app, app.router) + HueFullStateView(config).register(hass, app, app.router) async def _start(event: Event) -> None: """Start the bridge.""" diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 2d306ba5ee59..fda8717c3dde 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -365,7 +365,7 @@ class HomeAssistantHTTP: class_name = view.__class__.__name__ raise AttributeError(f'{class_name} missing required attribute "name"') - view.register(self.app, self.app.router) + view.register(self.hass, self.app, self.app.router) def register_redirect( self, diff --git a/homeassistant/components/http/view.py b/homeassistant/components/http/view.py index d39fca287829..abdcfe466c1a 100644 --- a/homeassistant/components/http/view.py +++ b/homeassistant/components/http/view.py @@ -19,7 +19,7 @@ import voluptuous as vol from homeassistant import exceptions from homeassistant.const import CONTENT_TYPE_JSON -from homeassistant.core import Context, is_callback +from homeassistant.core import Context, HomeAssistant, is_callback from homeassistant.helpers.json import ( find_paths_unserializable_data, json_bytes, @@ -27,7 +27,7 @@ from homeassistant.helpers.json import ( ) from homeassistant.util.json import JSON_ENCODE_EXCEPTIONS, format_unserializable_data -from .const import KEY_AUTHENTICATED, KEY_HASS +from .const import KEY_AUTHENTICATED _LOGGER = logging.getLogger(__name__) @@ -88,7 +88,9 @@ class HomeAssistantView: data["code"] = message_code return self.json(data, status_code, headers=headers) - def register(self, app: web.Application, router: web.UrlDispatcher) -> None: + def register( + self, hass: HomeAssistant, app: web.Application, router: web.UrlDispatcher + ) -> None: """Register the view with a router.""" assert self.url is not None, "No url set for view" urls = [self.url] + self.extra_urls @@ -98,7 +100,7 @@ class HomeAssistantView: if not (handler := getattr(self, method, None)): continue - handler = request_handler_factory(self, handler) + handler = request_handler_factory(hass, self, handler) for url in urls: routes.append(router.add_route(method, url, handler)) @@ -115,16 +117,17 @@ class HomeAssistantView: def request_handler_factory( - view: HomeAssistantView, handler: Callable + hass: HomeAssistant, view: HomeAssistantView, handler: Callable ) -> Callable[[web.Request], Awaitable[web.StreamResponse]]: """Wrap the handler classes.""" - assert asyncio.iscoroutinefunction(handler) or is_callback( + is_coroutinefunction = asyncio.iscoroutinefunction(handler) + assert is_coroutinefunction or is_callback( handler ), "Handler should be a coroutine or a callback." async def handle(request: web.Request) -> web.StreamResponse: """Handle incoming request.""" - if request.app[KEY_HASS].is_stopping: + if hass.is_stopping: return web.Response(status=HTTPStatus.SERVICE_UNAVAILABLE) authenticated = request.get(KEY_AUTHENTICATED, False) @@ -132,18 +135,19 @@ def request_handler_factory( if view.requires_auth and not authenticated: raise HTTPUnauthorized() - _LOGGER.debug( - "Serving %s to %s (auth: %s)", - request.path, - request.remote, - authenticated, - ) + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug( + "Serving %s to %s (auth: %s)", + request.path, + request.remote, + authenticated, + ) try: - result = handler(request, **request.match_info) - - if asyncio.iscoroutine(result): - result = await result + if is_coroutinefunction: + result = await handler(request, **request.match_info) + else: + result = handler(request, **request.match_info) except vol.Invalid as err: raise HTTPBadRequest() from err except exceptions.ServiceNotFound as err: @@ -156,21 +160,20 @@ def request_handler_factory( return result status_code = HTTPStatus.OK - if isinstance(result, tuple): result, status_code = result if isinstance(result, bytes): - bresult = result - elif isinstance(result, str): - bresult = result.encode("utf-8") - elif result is None: - bresult = b"" - else: - raise TypeError( - f"Result should be None, string, bytes or StreamResponse. Got: {result}" - ) + return web.Response(body=result, status=status_code) - return web.Response(body=bresult, status=status_code) + if isinstance(result, str): + return web.Response(text=result, status=status_code) + + if result is None: + return web.Response(body=b"", status=status_code) + + raise TypeError( + f"Result should be None, string, bytes or StreamResponse. Got: {result}" + ) return handle diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py index 153a9cac0cab..247a507bb69e 100644 --- a/tests/components/emulated_hue/test_hue_api.py +++ b/tests/components/emulated_hue/test_hue_api.py @@ -215,13 +215,13 @@ def _mock_hue_endpoints( web_app = hass.http.app config = Config(hass, conf, "127.0.0.1") config.numbers = entity_numbers - HueUsernameView().register(web_app, web_app.router) - HueAllLightsStateView(config).register(web_app, web_app.router) - HueOneLightStateView(config).register(web_app, web_app.router) - HueOneLightChangeView(config).register(web_app, web_app.router) - HueAllGroupsStateView(config).register(web_app, web_app.router) - HueFullStateView(config).register(web_app, web_app.router) - HueConfigView(config).register(web_app, web_app.router) + HueUsernameView().register(hass, web_app, web_app.router) + HueAllLightsStateView(config).register(hass, web_app, web_app.router) + HueOneLightStateView(config).register(hass, web_app, web_app.router) + HueOneLightChangeView(config).register(hass, web_app, web_app.router) + HueAllGroupsStateView(config).register(hass, web_app, web_app.router) + HueFullStateView(config).register(hass, web_app, web_app.router) + HueConfigView(config).register(hass, web_app, web_app.router) @pytest.fixture diff --git a/tests/components/http/test_ban.py b/tests/components/http/test_ban.py index 25574833d171..b34c8866ff11 100644 --- a/tests/components/http/test_ban.py +++ b/tests/components/http/test_ban.py @@ -333,13 +333,15 @@ async def test_failed_login_attempts_counter( return None, 200 app.router.add_get( - "/auth_true", request_handler_factory(Mock(requires_auth=True), auth_handler) + "/auth_true", + request_handler_factory(hass, Mock(requires_auth=True), auth_handler), ) app.router.add_get( - "/auth_false", request_handler_factory(Mock(requires_auth=True), auth_handler) + "/auth_false", + request_handler_factory(hass, Mock(requires_auth=True), auth_handler), ) app.router.add_get( - "/", request_handler_factory(Mock(requires_auth=False), auth_handler) + "/", request_handler_factory(hass, Mock(requires_auth=False), auth_handler) ) setup_bans(hass, app, 5) diff --git a/tests/components/http/test_data_validator.py b/tests/components/http/test_data_validator.py index 04f5dbf50f08..ecff43709994 100644 --- a/tests/components/http/test_data_validator.py +++ b/tests/components/http/test_data_validator.py @@ -27,7 +27,7 @@ async def get_client(aiohttp_client, validator): """Test method.""" return b"" - TestView().register(app, app.router) + TestView().register(app["hass"], app, app.router) client = await aiohttp_client(app) return client diff --git a/tests/components/http/test_view.py b/tests/components/http/test_view.py index 059c56b715db..e52413d5225d 100644 --- a/tests/components/http/test_view.py +++ b/tests/components/http/test_view.py @@ -20,13 +20,13 @@ from homeassistant.exceptions import ServiceNotFound, Unauthorized @pytest.fixture -def mock_request(): +def mock_request() -> Mock: """Mock a request.""" return Mock(app={"hass": Mock(is_stopping=False)}, match_info={}) @pytest.fixture -def mock_request_with_stopping(): +def mock_request_with_stopping() -> Mock: """Mock a request.""" return Mock(app={"hass": Mock(is_stopping=True)}, match_info={}) @@ -48,34 +48,51 @@ async def test_nan_serialized_to_null() -> None: assert json.loads(response.body.decode("utf-8")) is None -async def test_handling_unauthorized(mock_request) -> None: +async def test_handling_unauthorized(mock_request: Mock) -> None: """Test handling unauth exceptions.""" with pytest.raises(HTTPUnauthorized): await request_handler_factory( - Mock(requires_auth=False), AsyncMock(side_effect=Unauthorized) + mock_request.app["hass"], + Mock(requires_auth=False), + AsyncMock(side_effect=Unauthorized), )(mock_request) -async def test_handling_invalid_data(mock_request) -> None: +async def test_handling_invalid_data(mock_request: Mock) -> None: """Test handling unauth exceptions.""" with pytest.raises(HTTPBadRequest): await request_handler_factory( - Mock(requires_auth=False), AsyncMock(side_effect=vol.Invalid("yo")) + mock_request.app["hass"], + Mock(requires_auth=False), + AsyncMock(side_effect=vol.Invalid("yo")), )(mock_request) -async def test_handling_service_not_found(mock_request) -> None: +async def test_handling_service_not_found(mock_request: Mock) -> None: """Test handling unauth exceptions.""" with pytest.raises(HTTPInternalServerError): await request_handler_factory( + mock_request.app["hass"], Mock(requires_auth=False), AsyncMock(side_effect=ServiceNotFound("test", "test")), )(mock_request) -async def test_not_running(mock_request_with_stopping) -> None: +async def test_not_running(mock_request_with_stopping: Mock) -> None: """Test we get a 503 when not running.""" response = await request_handler_factory( - Mock(requires_auth=False), AsyncMock(side_effect=Unauthorized) + mock_request_with_stopping.app["hass"], + Mock(requires_auth=False), + AsyncMock(side_effect=Unauthorized), )(mock_request_with_stopping) assert response.status == HTTPStatus.SERVICE_UNAVAILABLE + + +async def test_invalid_handler(mock_request: Mock) -> None: + """Test an invalid handler.""" + with pytest.raises(TypeError): + await request_handler_factory( + mock_request.app["hass"], + Mock(requires_auth=False), + AsyncMock(return_value=["not valid"]), + )(mock_request) From c12fae47751e7f95fd17aeb0d1fefeddcd407ff0 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 21 May 2023 21:13:59 +0200 Subject: [PATCH 423/822] Bump TwitchAPI to 3.10.0 (#92418) Co-authored-by: Franck Nijhof --- homeassistant/components/twitch/manifest.json | 2 +- homeassistant/components/twitch/sensor.py | 106 +++++----- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/twitch/__init__.py | 189 ++++++++++++++++++ tests/components/twitch/test_twitch.py | 185 +++++++++-------- 6 files changed, 338 insertions(+), 148 deletions(-) diff --git a/homeassistant/components/twitch/manifest.json b/homeassistant/components/twitch/manifest.json index b954db7270be..c11be26c45a9 100644 --- a/homeassistant/components/twitch/manifest.json +++ b/homeassistant/components/twitch/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/twitch", "iot_class": "cloud_polling", "loggers": ["twitch"], - "requirements": ["twitchAPI==2.5.2"] + "requirements": ["twitchAPI==3.10.0"] } diff --git a/homeassistant/components/twitch/sensor.py b/homeassistant/components/twitch/sensor.py index 63746aae46fb..fcc8225b73c9 100644 --- a/homeassistant/components/twitch/sensor.py +++ b/homeassistant/components/twitch/sensor.py @@ -3,13 +3,17 @@ from __future__ import annotations import logging +from twitchAPI.helper import first from twitchAPI.twitch import ( AuthScope, AuthType, InvalidTokenException, MissingScopeException, Twitch, + TwitchAPIException, TwitchAuthorizationException, + TwitchResourceNotFound, + TwitchUser, ) import voluptuous as vol @@ -51,10 +55,10 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Twitch platform.""" @@ -64,7 +68,7 @@ def setup_platform( oauth_token = config.get(CONF_TOKEN) try: - client = Twitch( + client = await Twitch( app_id=client_id, app_secret=client_secret, target_app_auth_scope=OAUTH_SCOPES, @@ -76,7 +80,7 @@ def setup_platform( if oauth_token: try: - client.set_user_authentication( + await client.set_user_authentication( token=oauth_token, scope=OAUTH_SCOPES, validate=True ) except MissingScopeException: @@ -86,71 +90,45 @@ def setup_platform( _LOGGER.error("OAuth token is invalid") return - channels = client.get_users(logins=channels) + twitch_users: list[TwitchUser] = [] + async for channel in client.get_users(logins=channels): + twitch_users.append(channel) - add_entities( - [TwitchSensor(channel, client) for channel in channels["data"]], + async_add_entities( + [TwitchSensor(channel, client) for channel in twitch_users], True, ) class TwitchSensor(SensorEntity): - """Representation of an Twitch channel.""" + """Representation of a Twitch channel.""" _attr_icon = ICON - def __init__(self, channel: dict[str, str], client: Twitch) -> None: + def __init__(self, channel: TwitchUser, client: Twitch) -> None: """Initialize the sensor.""" self._client = client + self._channel = channel self._enable_user_auth = client.has_required_auth(AuthType.USER, OAUTH_SCOPES) - self._attr_name = channel["display_name"] - self._attr_unique_id = channel["id"] + self._attr_name = channel.display_name + self._attr_unique_id = channel.id - def update(self) -> None: + async def async_update(self) -> None: """Update device state.""" - followers = self._client.get_users_follows(to_id=self.unique_id)["total"] - channel = self._client.get_users(user_ids=[self.unique_id])["data"][0] + followers = (await self._client.get_users_follows(to_id=self._channel.id)).total self._attr_extra_state_attributes = { ATTR_FOLLOWING: followers, - ATTR_VIEWS: channel["view_count"], + ATTR_VIEWS: self._channel.view_count, } if self._enable_user_auth: - user = self._client.get_users()["data"][0]["id"] - - subs = self._client.check_user_subscription( - user_id=user, broadcaster_id=self.unique_id - ) - if "data" in subs: - self._attr_extra_state_attributes[ATTR_SUBSCRIPTION] = True - self._attr_extra_state_attributes[ATTR_SUBSCRIPTION_GIFTED] = subs[ - "data" - ][0]["is_gift"] - elif "status" in subs and subs["status"] == 404: - self._attr_extra_state_attributes[ATTR_SUBSCRIPTION] = False - elif "error" in subs: - _LOGGER.error( - "Error response on check_user_subscription: %s", subs["error"] - ) - return - else: - _LOGGER.error("Unknown error response on check_user_subscription") - return - - follows = self._client.get_users_follows( - from_id=user, to_id=self.unique_id - )["data"] - self._attr_extra_state_attributes[ATTR_FOLLOW] = len(follows) > 0 - if len(follows): - self._attr_extra_state_attributes[ATTR_FOLLOW_SINCE] = follows[0][ - "followed_at" - ] - - if streams := self._client.get_streams(user_id=[self.unique_id])["data"]: - stream = streams[0] + await self._async_add_user_attributes() + if stream := ( + await first(self._client.get_streams(user_id=[self._channel.id], first=1)) + ): self._attr_native_value = STATE_STREAMING - self._attr_extra_state_attributes[ATTR_GAME] = stream["game_name"] - self._attr_extra_state_attributes[ATTR_TITLE] = stream["title"] - self._attr_entity_picture = stream["thumbnail_url"] + self._attr_extra_state_attributes[ATTR_GAME] = stream.game_name + self._attr_extra_state_attributes[ATTR_TITLE] = stream.title + self._attr_entity_picture = stream.thumbnail_url if self._attr_entity_picture is not None: self._attr_entity_picture = self._attr_entity_picture.format( height=24, @@ -160,4 +138,30 @@ class TwitchSensor(SensorEntity): self._attr_native_value = STATE_OFFLINE self._attr_extra_state_attributes[ATTR_GAME] = None self._attr_extra_state_attributes[ATTR_TITLE] = None - self._attr_entity_picture = channel["profile_image_url"] + self._attr_entity_picture = self._channel.profile_image_url + + async def _async_add_user_attributes(self) -> None: + if not (user := await first(self._client.get_users())): + return + self._attr_extra_state_attributes[ATTR_SUBSCRIPTION] = False + try: + sub = await self._client.check_user_subscription( + user_id=user.id, broadcaster_id=self._channel.id + ) + self._attr_extra_state_attributes[ATTR_SUBSCRIPTION] = True + self._attr_extra_state_attributes[ATTR_SUBSCRIPTION_GIFTED] = sub.is_gift + except TwitchResourceNotFound: + _LOGGER.debug("User is not subscribed") + 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[ + 0 + ].followed_at diff --git a/requirements_all.txt b/requirements_all.txt index 4094cc2acc56..965ff04fd0f3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2556,7 +2556,7 @@ twentemilieu==1.0.0 twilio==6.32.0 # homeassistant.components.twitch -twitchAPI==2.5.2 +twitchAPI==3.10.0 # homeassistant.components.ukraine_alarm uasiren==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 05954c0f9e6a..cb197823f1d4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1847,7 +1847,7 @@ twentemilieu==1.0.0 twilio==6.32.0 # homeassistant.components.twitch -twitchAPI==2.5.2 +twitchAPI==3.10.0 # homeassistant.components.ukraine_alarm uasiren==0.0.1 diff --git a/tests/components/twitch/__init__.py b/tests/components/twitch/__init__.py index ec26cf264ef7..5c371a0e2ee4 100644 --- a/tests/components/twitch/__init__.py +++ b/tests/components/twitch/__init__.py @@ -1 +1,190 @@ """Tests for the Twitch component.""" +import asyncio +from collections.abc import AsyncGenerator +from dataclasses import dataclass +from typing import Any, Optional + +from twitchAPI.object import TwitchUser +from twitchAPI.twitch import ( + InvalidTokenException, + MissingScopeException, + TwitchAPIException, + TwitchAuthorizationException, + TwitchResourceNotFound, +) +from twitchAPI.types 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, +) + + +class TwitchUserFollowResultMock: + """Mock for twitch user follow result.""" + + def __init__(self, follows: list[dict[str, Any]]) -> None: + """Initialize mock.""" + self.total = len(follows) + self.data = follows + + +@dataclass +class UserSubscriptionMock: + """User subscription mock.""" + + broadcaster_id: str + is_gift: bool + + +@dataclass +class UserFollowMock: + """User follow mock.""" + + followed_at: str + + +@dataclass +class StreamMock: + """Stream mock.""" + + game_name: str + title: str + thumbnail_url: str + + +STREAMS = StreamMock( + game_name="Good game", title="Title", thumbnail_url="stream-medium.png" +) + + +class TwitchMock: + """Mock for the twitch object.""" + + 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 + + async def get_users( + self, user_ids: Optional[list[str]] = None, logins: Optional[list[str]] = None + ) -> AsyncGenerator[TwitchUser, None]: + """Get list of mock users.""" + for user in [USER_OBJECT]: + yield user + + def has_required_auth( + self, required_type: AuthType, required_scope: list[AuthScope] + ) -> bool: + """Return if auth required.""" + return True + + async def get_users_follows( + self, to_id: Optional[str] = None, from_id: Optional[str] = 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: + return UserSubscriptionMock( + 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 + ) -> None: + """Set user authentication.""" + pass + + async def get_streams( + self, user_id: list[str], first: int + ) -> AsyncGenerator[StreamMock, None]: + """Get streams for the user.""" + streams = [] + if self._is_streaming: + streams = [STREAMS] + for stream in streams: + yield stream + + +class TwitchUnauthorizedMock(TwitchMock): + """Twitch mock to test if the client is unauthorized.""" + + def __await__(self): + """Add async capabilities to the mock.""" + raise TwitchAuthorizationException() + + +class TwitchMissingScopeMock(TwitchMock): + """Twitch mock to test missing scopes.""" + + async def set_user_authentication( + self, token: str, scope: list[AuthScope], validate: bool = True + ) -> None: + """Set user authentication.""" + raise MissingScopeException() + + +class TwitchInvalidTokenMock(TwitchMock): + """Twitch mock to test invalid token.""" + + async def set_user_authentication( + self, token: str, scope: list[AuthScope], validate: bool = True + ) -> None: + """Set user authentication.""" + raise InvalidTokenException() + + +class TwitchInvalidUserMock(TwitchMock): + """Twitch mock to test invalid user.""" + + async def get_users( + self, user_ids: Optional[list[str]] = None, logins: Optional[list[str]] = None + ) -> AsyncGenerator[TwitchUser, None]: + """Get list of mock users.""" + if user_ids is not None or logins is not None: + async for user in super().get_users(user_ids, logins): + yield user + else: + for user in []: + yield user + + +class TwitchAPIExceptionMock(TwitchMock): + """Twitch mock to test when twitch api throws unknown exception.""" + + async def check_user_subscription( + self, broadcaster_id: str, user_id: str + ) -> UserSubscriptionMock: + """Check if the user is subscribed.""" + raise TwitchAPIException() diff --git a/tests/components/twitch/test_twitch.py b/tests/components/twitch/test_twitch.py index fb932c8f35cc..4a33831dd32e 100644 --- a/tests/components/twitch/test_twitch.py +++ b/tests/components/twitch/test_twitch.py @@ -1,11 +1,20 @@ """The tests for an update of the Twitch component.""" -from unittest.mock import MagicMock, patch +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: { @@ -25,41 +34,13 @@ CONFIG_WITH_OAUTH = { } } -USER_OBJECT = { - "id": 123, - "display_name": "channel123", - "offline_image_url": "logo.png", - "profile_image_url": "logo.png", - "view_count": 42, -} -STREAM_OBJECT_ONLINE = { - "game_name": "Good Game", - "title": "Title", - "thumbnail_url": "stream-medium.png", -} - -FOLLOWERS_OBJECT = [{"followed_at": "2020-01-20T21:22:42"}] * 24 -OAUTH_USER_ID = {"id": 987} -SUB_ACTIVE = {"is_gift": False} -FOLLOW_ACTIVE = {"followed_at": "2020-01-20T21:22:42"} - - -def make_data(data): - """Create a data object.""" - return {"data": data, "total": len(data)} - async def test_init(hass: HomeAssistant) -> None: """Test initial config.""" - twitch_mock = MagicMock() - twitch_mock.get_streams.return_value = make_data([]) - twitch_mock.get_users.return_value = make_data([USER_OBJECT]) - twitch_mock.get_users_follows.return_value = make_data(FOLLOWERS_OBJECT) - twitch_mock.has_required_auth.return_value = False - with patch( - "homeassistant.components.twitch.sensor.Twitch", return_value=twitch_mock + "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() @@ -76,15 +57,9 @@ async def test_init(hass: HomeAssistant) -> None: async def test_offline(hass: HomeAssistant) -> None: """Test offline state.""" - twitch_mock = MagicMock() - twitch_mock.get_streams.return_value = make_data([]) - twitch_mock.get_users.return_value = make_data([USER_OBJECT]) - twitch_mock.get_users_follows.return_value = make_data(FOLLOWERS_OBJECT) - twitch_mock.has_required_auth.return_value = False - with patch( "homeassistant.components.twitch.sensor.Twitch", - return_value=twitch_mock, + return_value=TwitchMock(is_streaming=False), ): assert await async_setup_component(hass, sensor.DOMAIN, CONFIG) is True await hass.async_block_till_done() @@ -97,15 +72,9 @@ async def test_offline(hass: HomeAssistant) -> None: async def test_streaming(hass: HomeAssistant) -> None: """Test streaming state.""" - twitch_mock = MagicMock() - twitch_mock.get_users.return_value = make_data([USER_OBJECT]) - twitch_mock.get_users_follows.return_value = make_data(FOLLOWERS_OBJECT) - twitch_mock.get_streams.return_value = make_data([STREAM_OBJECT_ONLINE]) - twitch_mock.has_required_auth.return_value = False - with patch( "homeassistant.components.twitch.sensor.Twitch", - return_value=twitch_mock, + return_value=TwitchMock(), ): assert await async_setup_component(hass, sensor.DOMAIN, CONFIG) is True await hass.async_block_till_done() @@ -113,30 +82,16 @@ async def test_streaming(hass: HomeAssistant) -> None: 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["game"] == "Good game" assert sensor_state.attributes["title"] == "Title" async def test_oauth_without_sub_and_follow(hass: HomeAssistant) -> None: """Test state with oauth.""" - twitch_mock = MagicMock() - twitch_mock.get_streams.return_value = make_data([]) - twitch_mock.get_users.side_effect = [ - make_data([USER_OBJECT]), - make_data([USER_OBJECT]), - make_data([OAUTH_USER_ID]), - ] - twitch_mock.get_users_follows.side_effect = [ - make_data(FOLLOWERS_OBJECT), - make_data([]), - ] - twitch_mock.has_required_auth.return_value = True - twitch_mock.check_user_subscription.return_value = {"status": 404} - with patch( "homeassistant.components.twitch.sensor.Twitch", - return_value=twitch_mock, + return_value=TwitchMock(is_following=False), ): assert await async_setup_component(hass, sensor.DOMAIN, CONFIG_WITH_OAUTH) await hass.async_block_till_done() @@ -149,25 +104,11 @@ async def test_oauth_without_sub_and_follow(hass: HomeAssistant) -> None: async def test_oauth_with_sub(hass: HomeAssistant) -> None: """Test state with oauth and sub.""" - twitch_mock = MagicMock() - twitch_mock.get_streams.return_value = make_data([]) - twitch_mock.get_users.side_effect = [ - make_data([USER_OBJECT]), - make_data([USER_OBJECT]), - make_data([OAUTH_USER_ID]), - ] - twitch_mock.get_users_follows.side_effect = [ - make_data(FOLLOWERS_OBJECT), - make_data([]), - ] - twitch_mock.has_required_auth.return_value = True - - # This function does not return an array so use make_data - twitch_mock.check_user_subscription.return_value = make_data([SUB_ACTIVE]) - with patch( "homeassistant.components.twitch.sensor.Twitch", - return_value=twitch_mock, + 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() @@ -181,28 +122,84 @@ async def test_oauth_with_sub(hass: HomeAssistant) -> None: async def test_oauth_with_follow(hass: HomeAssistant) -> None: """Test state with oauth and follow.""" - twitch_mock = MagicMock() - twitch_mock.get_streams.return_value = make_data([]) - twitch_mock.get_users.side_effect = [ - make_data([USER_OBJECT]), - make_data([USER_OBJECT]), - make_data([OAUTH_USER_ID]), - ] - twitch_mock.get_users_follows.side_effect = [ - make_data(FOLLOWERS_OBJECT), - make_data([FOLLOW_ACTIVE]), - ] - twitch_mock.has_required_auth.return_value = True - twitch_mock.check_user_subscription.return_value = {"status": 404} + 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=twitch_mock, + 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 sensor_state.attributes["following"] is True - assert sensor_state.attributes["following_since"] == "2020-01-20T21:22:42" + assert "subscription_is_gifted" not in sensor_state.attributes From 2cc9fcc977d8b952b29340e11dbb7bc64af57caf Mon Sep 17 00:00:00 2001 From: Luke Date: Sun, 21 May 2023 15:29:33 -0400 Subject: [PATCH 424/822] Add scaffolds to vscode tasks (#92015) --- .vscode/tasks.json | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/.vscode/tasks.json b/.vscode/tasks.json index c484045530d2..c767647f821a 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -137,6 +137,26 @@ "kind": "build", "isDefault": true } + }, + { + "label": "Run scaffold", + "detail": "Add new functionality to a integration using a scaffold.", + "type": "shell", + "command": "python3 -m script.scaffold ${input:scaffoldName} --integration ${input:integrationName}", + "group": { + "kind": "build", + "isDefault": true + } + }, + { + "label": "Create new integration", + "detail": "Use the scaffold to create a new integration.", + "type": "shell", + "command": "python3 -m script.scaffold integration", + "group": { + "kind": "build", + "isDefault": true + } } ], "inputs": [ @@ -144,6 +164,23 @@ "id": "integrationName", "type": "promptString", "description": "For which integration should the task run?" + }, + { + "id": "scaffoldName", + "type": "pickString", + "options": [ + "backup", + "config_flow", + "config_flow_discovery", + "config_flow_helper", + "config_flow_oauth2", + "device_action", + "device_condition", + "device_trigger", + "reproduce_state", + "significant_change" + ], + "description": "Which scaffold should be run?" } ] } From c3cd60e0cb66f2ddae5165d652b091cec2ed8b96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Sun, 21 May 2023 22:17:26 +0200 Subject: [PATCH 425/822] Fix pressure for AEMET stations close to sea level (#87094) --- homeassistant/components/aemet/weather_update_coordinator.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/aemet/weather_update_coordinator.py b/homeassistant/components/aemet/weather_update_coordinator.py index 1c64206891c2..5242540748f5 100644 --- a/homeassistant/components/aemet/weather_update_coordinator.py +++ b/homeassistant/components/aemet/weather_update_coordinator.py @@ -26,6 +26,7 @@ from aemet_opendata.const import ( AEMET_ATTR_STATION_DATE, AEMET_ATTR_STATION_HUMIDITY, AEMET_ATTR_STATION_LOCATION, + AEMET_ATTR_STATION_PRESSURE, AEMET_ATTR_STATION_PRESSURE_SEA, AEMET_ATTR_STATION_TEMPERATURE, AEMET_ATTR_STORM_PROBABILITY, @@ -318,6 +319,8 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): pressure = format_float( station_data[AEMET_ATTR_STATION_PRESSURE_SEA] ) + elif AEMET_ATTR_STATION_PRESSURE in station_data: + pressure = format_float(station_data[AEMET_ATTR_STATION_PRESSURE]) if AEMET_ATTR_STATION_TEMPERATURE in station_data: temperature = format_float( station_data[AEMET_ATTR_STATION_TEMPERATURE] From 63c2b5868eed5a90004956fd8bb4cff8317d4f68 Mon Sep 17 00:00:00 2001 From: Steven Looman Date: Mon, 22 May 2023 00:17:08 +0200 Subject: [PATCH 426/822] Bump async-upnp-client to 0.33.2 (#93329) * Bump async-upnp-client to 0.33.2 * Fix tests --- 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 +- tests/components/ssdp/test_init.py | 12 ++++++++++++ 10 files changed, 21 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json index eefc4d85a693..322cd1e4d2bc 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.33.1", "getmac==0.8.2"], + "requirements": ["async-upnp-client==0.33.2", "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 35f028338c94..227a343a7a49 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.33.1"], + "requirements": ["async-upnp-client==0.33.2"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaServer:1", diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index 027badc189f6..9d00282d8daa 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.33.1" + "async-upnp-client==0.33.2" ], "ssdp": [ { diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json index cd1245c653cc..caae5801b21a 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.33.1"] + "requirements": ["async-upnp-client==0.33.2"] } diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index 1ffb8cfd9466..8112726607e2 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.33.1", "getmac==0.8.2"], + "requirements": ["async-upnp-client==0.33.2", "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 a3d4e900c571..c6f54b45f1e8 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.10", "async-upnp-client==0.33.1"], + "requirements": ["yeelight==0.7.10", "async-upnp-client==0.33.2"], "zeroconf": [ { "type": "_miio._udp.local.", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 39d53b69dcea..033d87d63c31 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -5,7 +5,7 @@ aiodiscover==1.4.16 aiohttp==3.8.4 aiohttp_cors==0.7.0 astral==2.2 -async-upnp-client==0.33.1 +async-upnp-client==0.33.2 async_timeout==4.0.2 atomicwrites-homeassistant==1.4.1 attrs==22.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 965ff04fd0f3..9d0d93056c5b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -377,7 +377,7 @@ asterisk_mbox==0.5.0 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.33.1 +async-upnp-client==0.33.2 # homeassistant.components.supla asyncpysupla==0.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cb197823f1d4..3c8c913e5705 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -337,7 +337,7 @@ arcam-fmj==1.3.0 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.33.1 +async-upnp-client==0.33.2 # homeassistant.components.sleepiq asyncsleepiq==1.3.5 diff --git a/tests/components/ssdp/test_init.py b/tests/components/ssdp/test_init.py index b068aed11abb..a80b9f487985 100644 --- a/tests/components/ssdp/test_init.py +++ b/tests/components/ssdp/test_init.py @@ -53,6 +53,7 @@ async def test_ssdp_flow_dispatched_on_st( "usn": "uuid:mock-udn::mock-st", "server": "mock-server", "ext": "", + "_source": "search", } ) ssdp_listener = await init_ssdp_component(hass) @@ -96,6 +97,7 @@ async def test_ssdp_flow_dispatched_on_manufacturer_url( "usn": "uuid:mock-udn::mock-st", "server": "mock-server", "ext": "", + "_source": "search", } ) ssdp_listener = await init_ssdp_component(hass) @@ -149,6 +151,7 @@ async def test_scan_match_upnp_devicedesc_manufacturer( "st": "mock-st", "location": "http://1.1.1.1", "usn": "uuid:mock-udn::mock-st", + "_source": "search", } ) ssdp_listener = await init_ssdp_component(hass) @@ -193,6 +196,7 @@ async def test_scan_match_upnp_devicedesc_devicetype( "st": "mock-st", "location": "http://1.1.1.1", "usn": "uuid:mock-udn::mock-st", + "_source": "search", } ) ssdp_listener = await init_ssdp_component(hass) @@ -290,6 +294,7 @@ async def test_scan_not_all_match( "st": "mock-st", "location": "http://1.1.1.1", "usn": "uuid:mock-udn::mock-st", + "_source": "search", } ) ssdp_listener = await init_ssdp_component(hass) @@ -333,6 +338,7 @@ async def test_flow_start_only_alive( "st": "mock-st", "location": "http://1.1.1.1", "usn": "uuid:mock-udn::mock-st", + "_source": "search", } ) ssdp_listener._on_search(mock_ssdp_search_response) @@ -350,6 +356,7 @@ async def test_flow_start_only_alive( "usn": "uuid:mock-udn::mock-st", "nt": "upnp:rootdevice", "nts": "ssdp:alive", + "_source": "advertisement", } ) ssdp_listener._on_alive(mock_ssdp_advertisement) @@ -407,6 +414,7 @@ async def test_discovery_from_advertisement_sets_ssdp_st( "nts": "ssdp:alive", "location": "http://1.1.1.1", "usn": "uuid:mock-udn::mock-st", + "_source": "advertisement", } ) ssdp_listener._on_alive(mock_ssdp_advertisement) @@ -481,6 +489,7 @@ async def test_scan_with_registered_callback( "server": "mock-server", "x-rincon-bootseq": "55", "ext": "", + "_source": "search", } ) ssdp_listener = await init_ssdp_component(hass) @@ -577,6 +586,7 @@ async def test_getting_existing_headers( "USN": "uuid:TIVRTLSR7ANF-D6E-1557809135086-RETAIL::urn:mdx-netflix-com:service:target:3", "SERVER": "mock-server", "EXT": "", + "_source": "search", } ) ssdp_listener = await init_ssdp_component(hass) @@ -818,6 +828,7 @@ async def test_flow_dismiss_on_byebye( "st": "mock-st", "location": "http://1.1.1.1", "usn": "uuid:mock-udn::mock-st", + "_source": "search", } ) ssdp_listener._on_search(mock_ssdp_search_response) @@ -835,6 +846,7 @@ async def test_flow_dismiss_on_byebye( "usn": "uuid:mock-udn::mock-st", "nt": "upnp:rootdevice", "nts": "ssdp:alive", + "_source": "advertisement", } ) ssdp_listener._on_alive(mock_ssdp_advertisement) From 69770bac2261c1a9373a7818ab9187df2555f785 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 22 May 2023 04:09:52 +0200 Subject: [PATCH 427/822] Update sentry-sdk to 1.23.1 (#93330) --- 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 8db94696e717..d684ee345b1b 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.21.0"] + "requirements": ["sentry-sdk==1.23.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9d0d93056c5b..be2d0dcdbc55 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2339,7 +2339,7 @@ sensorpro-ble==0.5.3 sensorpush-ble==1.5.5 # homeassistant.components.sentry -sentry-sdk==1.21.0 +sentry-sdk==1.23.1 # homeassistant.components.sfr_box sfrbox-api==0.0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3c8c913e5705..75401fd480d4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1690,7 +1690,7 @@ sensorpro-ble==0.5.3 sensorpush-ble==1.5.5 # homeassistant.components.sentry -sentry-sdk==1.21.0 +sentry-sdk==1.23.1 # homeassistant.components.sfr_box sfrbox-api==0.0.6 From 31c0374c980bafbbb3b869c42467a654f74d37e5 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 22 May 2023 07:31:02 +0200 Subject: [PATCH 428/822] Update apprise to 1.4.0 (#93331) --- 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 453fc7735144..9a56f5d91ebb 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.3.0"] + "requirements": ["apprise==1.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index be2d0dcdbc55..649f34102151 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -348,7 +348,7 @@ anthemav==1.4.1 apcaccess==0.0.13 # homeassistant.components.apprise -apprise==1.3.0 +apprise==1.4.0 # homeassistant.components.aprs aprslib==0.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 75401fd480d4..5ee158eccfcb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -320,7 +320,7 @@ anthemav==1.4.1 apcaccess==0.0.13 # homeassistant.components.apprise -apprise==1.3.0 +apprise==1.4.0 # homeassistant.components.aprs aprslib==0.7.0 From aa937b16400c937b884c5ef1c5865758935f478a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 May 2023 01:52:49 -0500 Subject: [PATCH 429/822] Use slots for template RenderInfo (#93337) --- homeassistant/helpers/template.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 6ff48d832417..d40a0289ab8c 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -329,6 +329,22 @@ _cached_literal_eval = lru_cache(maxsize=EVAL_CACHE_SIZE)(literal_eval) class RenderInfo: """Holds information about a template render.""" + __slots__ = ( + "template", + "filter_lifecycle", + "filter", + "_result", + "is_static", + "exception", + "all_states", + "all_states_lifecycle", + "domains", + "domains_lifecycle", + "entities", + "rate_limit", + "has_time", + ) + def __init__(self, template: Template) -> None: """Initialise.""" self.template = template From 79cafd5a463c4f701c6cd3483036bf38d179720c Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 May 2023 09:19:00 +0200 Subject: [PATCH 430/822] Plugwise prepare typing for binary_sensor (#93162) --- .../components/plugwise/binary_sensor.py | 34 ++++++++++++++----- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/plugwise/binary_sensor.py b/homeassistant/components/plugwise/binary_sensor.py index 3fce1509c0f3..ac7a4f796a26 100644 --- a/homeassistant/components/plugwise/binary_sensor.py +++ b/homeassistant/components/plugwise/binary_sensor.py @@ -1,10 +1,12 @@ """Plugwise Binary Sensor component for Home Assistant.""" from __future__ import annotations -from collections.abc import Mapping +from collections.abc import Callable, Mapping from dataclasses import dataclass from typing import Any +from plugwise import SmileBinarySensors + from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, @@ -22,7 +24,16 @@ SEVERITIES = ["other", "info", "warning", "error"] @dataclass -class PlugwiseBinarySensorEntityDescription(BinarySensorEntityDescription): +class PlugwiseBinarySensorMixin: + """Mixin for required Plugwise binary sensor base description keys.""" + + value_fn: Callable[[SmileBinarySensors], bool] + + +@dataclass +class PlugwiseBinarySensorEntityDescription( + BinarySensorEntityDescription, PlugwiseBinarySensorMixin +): """Describes a Plugwise binary sensor entity.""" icon_off: str | None = None @@ -35,12 +46,14 @@ BINARY_SENSORS: tuple[PlugwiseBinarySensorEntityDescription, ...] = ( icon="mdi:hvac", icon_off="mdi:hvac-off", entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data["compressor_state"], ), PlugwiseBinarySensorEntityDescription( key="cooling_enabled", translation_key="cooling_enabled", icon="mdi:snowflake-thermometer", entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data["cooling_enabled"], ), PlugwiseBinarySensorEntityDescription( key="dhw_state", @@ -48,6 +61,7 @@ BINARY_SENSORS: tuple[PlugwiseBinarySensorEntityDescription, ...] = ( icon="mdi:water-pump", icon_off="mdi:water-pump-off", entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data["dhw_state"], ), PlugwiseBinarySensorEntityDescription( key="flame_state", @@ -56,6 +70,7 @@ BINARY_SENSORS: tuple[PlugwiseBinarySensorEntityDescription, ...] = ( icon="mdi:fire", icon_off="mdi:fire-off", entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data["flame_state"], ), PlugwiseBinarySensorEntityDescription( key="heating_state", @@ -63,6 +78,7 @@ BINARY_SENSORS: tuple[PlugwiseBinarySensorEntityDescription, ...] = ( icon="mdi:radiator", icon_off="mdi:radiator-off", entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data["heating_state"], ), PlugwiseBinarySensorEntityDescription( key="cooling_state", @@ -70,6 +86,7 @@ BINARY_SENSORS: tuple[PlugwiseBinarySensorEntityDescription, ...] = ( icon="mdi:snowflake", icon_off="mdi:snowflake-off", entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data["cooling_state"], ), PlugwiseBinarySensorEntityDescription( key="slave_boiler_state", @@ -77,6 +94,7 @@ BINARY_SENSORS: tuple[PlugwiseBinarySensorEntityDescription, ...] = ( icon="mdi:fire", icon_off="mdi:circle-off-outline", entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data["slave_boiler_state"], ), PlugwiseBinarySensorEntityDescription( key="plugwise_notification", @@ -84,6 +102,7 @@ BINARY_SENSORS: tuple[PlugwiseBinarySensorEntityDescription, ...] = ( icon="mdi:mailbox-up-outline", icon_off="mdi:mailbox-outline", entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data["plugwise_notification"], ), ) @@ -100,11 +119,10 @@ async def async_setup_entry( entities: list[PlugwiseBinarySensorEntity] = [] for device_id, device in coordinator.data.devices.items(): + if not (binary_sensors := device.get("binary_sensors")): + continue for description in BINARY_SENSORS: - if description.key not in device and ( - "binary_sensors" not in device - or description.key not in device["binary_sensors"] - ): + if description.key not in binary_sensors: continue entities.append( @@ -136,9 +154,7 @@ class PlugwiseBinarySensorEntity(PlugwiseEntity, BinarySensorEntity): @property def is_on(self) -> bool | None: """Return true if the binary sensor is on.""" - if self.entity_description.key in self.device: - return self.device[self.entity_description.key] - return self.device["binary_sensors"].get(self.entity_description.key) + return self.entity_description.value_fn(self.device["binary_sensors"]) @property def icon(self) -> str | None: From d16452bed7279cbcaaf0d2de74e0190ca132502e Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Mon, 22 May 2023 02:14:52 -0600 Subject: [PATCH 431/822] Bump `aionotion` to 2023.05.5 (#93334) --- homeassistant/components/notion/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/notion/test_diagnostics.py | 12 ++++++++---- 4 files changed, 11 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/notion/manifest.json b/homeassistant/components/notion/manifest.json index 168899c38e0f..f23a082df35e 100644 --- a/homeassistant/components/notion/manifest.json +++ b/homeassistant/components/notion/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["aionotion"], - "requirements": ["aionotion==2023.05.4"] + "requirements": ["aionotion==2023.05.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 649f34102151..68167b3ca400 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -223,7 +223,7 @@ aionanoleaf==0.2.1 aionotify==0.2.0 # homeassistant.components.notion -aionotion==2023.05.4 +aionotion==2023.05.5 # homeassistant.components.oncue aiooncue==0.3.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5ee158eccfcb..872e71ae6bf8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -204,7 +204,7 @@ aiomusiccast==0.14.8 aionanoleaf==0.2.1 # homeassistant.components.notion -aionotion==2023.05.4 +aionotion==2023.05.5 # homeassistant.components.oncue aiooncue==0.3.4 diff --git a/tests/components/notion/test_diagnostics.py b/tests/components/notion/test_diagnostics.py index b59b995b4040..14a1a0e17685 100644 --- a/tests/components/notion/test_diagnostics.py +++ b/tests/components/notion/test_diagnostics.py @@ -37,18 +37,20 @@ async def test_entry_diagnostics( "hardware_id": REDACTED, "hardware_revision": 4, "firmware_version": { - "silabs": "1.1.2", "wifi": "0.121.0", "wifi_app": "3.3.0", + "silabs": "1.1.2", + "ti": None, }, "missing_at": None, "created_at": "2019-06-27T00:18:44.337000+00:00", "updated_at": "2023-03-19T03:20:16.061000+00:00", "system_id": 11111, "firmware": { - "silabs": "1.1.2", "wifi": "0.121.0", "wifi_app": "3.3.0", + "silabs": "1.1.2", + "ti": None, }, "links": {"system": 11111}, }, @@ -59,18 +61,20 @@ async def test_entry_diagnostics( "hardware_id": REDACTED, "hardware_revision": 4, "firmware_version": { - "silabs": "1.1.2", "wifi": "0.121.0", "wifi_app": "3.3.0", + "silabs": "1.1.2", + "ti": None, }, "missing_at": None, "created_at": "2019-04-30T01:43:50.497000+00:00", "updated_at": "2023-01-02T19:09:58.251000+00:00", "system_id": 11111, "firmware": { - "silabs": "1.1.2", "wifi": "0.121.0", "wifi_app": "3.3.0", + "silabs": "1.1.2", + "ti": None, }, "links": {"system": 11111}, }, From 4b67839e195ce4700ef6708a9c0cad67915346c6 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Mon, 22 May 2023 05:06:34 -0400 Subject: [PATCH 432/822] Bump zwave-js-server-python to 0.48.1 (#93342) * Bump zwave-js-server-python to 0.48.1 * fix mypy --- homeassistant/components/zwave_js/discovery.py | 6 +++--- homeassistant/components/zwave_js/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index a43482e3e906..bea2836fead7 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -976,19 +976,19 @@ def async_discover_single_value( continue # check device_class_basic - if not check_device_class( + if value.node.device_class and not check_device_class( value.node.device_class.basic, schema.device_class_basic ): continue # check device_class_generic - if not check_device_class( + if value.node.device_class and not check_device_class( value.node.device_class.generic, schema.device_class_generic ): continue # check device_class_specific - if not check_device_class( + if value.node.device_class and not check_device_class( value.node.device_class.specific, schema.device_class_specific ): continue diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 8452ba2ed32d..da144c398ed5 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -8,7 +8,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["zwave_js_server"], - "requirements": ["pyserial==3.5", "zwave-js-server-python==0.48.0"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.48.1"], "usb": [ { "vid": "0658", diff --git a/requirements_all.txt b/requirements_all.txt index 68167b3ca400..733c2815c0b8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2754,7 +2754,7 @@ zigpy==0.55.0 zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.48.0 +zwave-js-server-python==0.48.1 # homeassistant.components.zwave_me zwave_me_ws==0.4.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 872e71ae6bf8..7e61e0d53f38 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2003,7 +2003,7 @@ zigpy-znp==0.11.1 zigpy==0.55.0 # homeassistant.components.zwave_js -zwave-js-server-python==0.48.0 +zwave-js-server-python==0.48.1 # homeassistant.components.zwave_me zwave_me_ws==0.4.2 From 5bc825a8ab396a975deac39230af957e19db070c Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 22 May 2023 12:14:06 +0200 Subject: [PATCH 433/822] Add advanced imap option to set custom event max message size (#93163) --- homeassistant/components/imap/config_flow.py | 17 +++++- homeassistant/components/imap/const.py | 5 ++ homeassistant/components/imap/coordinator.py | 21 ++++++- homeassistant/components/imap/strings.json | 3 +- tests/components/imap/test_config_flow.py | 49 ++++++++++++++++ tests/components/imap/test_init.py | 59 ++++++++++++++++++++ 6 files changed, 150 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/imap/config_flow.py b/homeassistant/components/imap/config_flow.py index 71b09048e6f1..8724dbf97c01 100644 --- a/homeassistant/components/imap/config_flow.py +++ b/homeassistant/components/imap/config_flow.py @@ -24,11 +24,14 @@ from homeassistant.util.ssl import SSLCipherList from .const import ( CONF_CHARSET, CONF_FOLDER, + CONF_MAX_MESSAGE_SIZE, CONF_SEARCH, CONF_SERVER, CONF_SSL_CIPHER_LIST, + DEFAULT_MAX_MESSAGE_SIZE, DEFAULT_PORT, DOMAIN, + MAX_MESSAGE_SIZE_LIMIT, ) from .coordinator import connect_to_server from .errors import InvalidAuth, InvalidFolder @@ -55,7 +58,7 @@ CONFIG_SCHEMA = vol.Schema( CONFIG_SCHEMA_ADVANCED = { vol.Optional( CONF_SSL_CIPHER_LIST, default=SSLCipherList.PYTHON_DEFAULT - ): CIPHER_SELECTOR + ): CIPHER_SELECTOR, } OPTIONS_SCHEMA = vol.Schema( @@ -65,6 +68,13 @@ OPTIONS_SCHEMA = vol.Schema( } ) +OPTIONS_SCHEMA_ADVANCED = { + vol.Optional(CONF_MAX_MESSAGE_SIZE, default=DEFAULT_MAX_MESSAGE_SIZE): vol.All( + cv.positive_int, + vol.Range(min=DEFAULT_MAX_MESSAGE_SIZE, max=MAX_MESSAGE_SIZE_LIMIT), + ) +} + async def validate_input(user_input: dict[str, Any]) -> dict[str, str]: """Validate user input.""" @@ -233,6 +243,9 @@ class OptionsFlow(config_entries.OptionsFlowWithConfigEntry): ) return self.async_create_entry(data={}) - schema = self.add_suggested_values_to_schema(OPTIONS_SCHEMA, entry_data) + schema = OPTIONS_SCHEMA + if self.show_advanced_options: + schema = schema.extend(OPTIONS_SCHEMA_ADVANCED) + schema = self.add_suggested_values_to_schema(schema, entry_data) return self.async_show_form(step_id="init", data_schema=schema, errors=errors) diff --git a/homeassistant/components/imap/const.py b/homeassistant/components/imap/const.py index a1ca586b48b1..b39c88086331 100644 --- a/homeassistant/components/imap/const.py +++ b/homeassistant/components/imap/const.py @@ -8,6 +8,11 @@ CONF_SERVER: Final = "server" CONF_FOLDER: Final = "folder" CONF_SEARCH: Final = "search" CONF_CHARSET: Final = "charset" +CONF_MAX_MESSAGE_SIZE = "max_message_size" CONF_SSL_CIPHER_LIST: Final = "ssl_cipher_list" DEFAULT_PORT: Final = 993 + +DEFAULT_MAX_MESSAGE_SIZE = 2048 + +MAX_MESSAGE_SIZE_LIMIT = 30000 diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index 0f53c5928c95..512df9adf51c 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -20,15 +20,18 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryError +from homeassistant.helpers.json import json_bytes from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util.ssl import SSLCipherList, client_context from .const import ( CONF_CHARSET, CONF_FOLDER, + CONF_MAX_MESSAGE_SIZE, CONF_SEARCH, CONF_SERVER, CONF_SSL_CIPHER_LIST, + DEFAULT_MAX_MESSAGE_SIZE, DOMAIN, ) from .errors import InvalidAuth, InvalidFolder @@ -38,6 +41,7 @@ _LOGGER = logging.getLogger(__name__) BACKOFF_TIME = 10 EVENT_IMAP = "imap_content" +MAX_EVENT_DATA_BYTES = 32168 async def connect_to_server(data: Mapping[str, Any]) -> IMAP4_SSL: @@ -177,11 +181,26 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): "search": self.config_entry.data[CONF_SEARCH], "folder": self.config_entry.data[CONF_FOLDER], "date": message.date, - "text": message.text[:2048], + "text": message.text[ + : self.config_entry.data.get( + CONF_MAX_MESSAGE_SIZE, DEFAULT_MAX_MESSAGE_SIZE + ) + ], "sender": message.sender, "subject": message.subject, "headers": message.headers, } + if (size := len(json_bytes(data))) > MAX_EVENT_DATA_BYTES: + _LOGGER.warning( + "Custom imap_content event skipped, size (%s) exceeds " + "the maximal event size (%s), sender: %s, subject: %s", + size, + MAX_EVENT_DATA_BYTES, + message.sender, + message.subject, + ) + return + self.hass.bus.fire(EVENT_IMAP, data) _LOGGER.debug( "Message processed, sender: %s, subject: %s", diff --git a/homeassistant/components/imap/strings.json b/homeassistant/components/imap/strings.json index e50370dd9b1c..6e97fbe69d84 100644 --- a/homeassistant/components/imap/strings.json +++ b/homeassistant/components/imap/strings.json @@ -39,7 +39,8 @@ "init": { "data": { "folder": "[%key:component::imap::config::step::user::data::folder%]", - "search": "[%key:component::imap::config::step::user::data::search%]" + "search": "[%key:component::imap::config::step::user::data::search%]", + "max_message_size": "Max message size (2048 < size < 30000)" } } }, diff --git a/tests/components/imap/test_config_flow.py b/tests/components/imap/test_config_flow.py index 82430549f05d..b8242a87be84 100644 --- a/tests/components/imap/test_config_flow.py +++ b/tests/components/imap/test_config_flow.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, patch from aioimaplib import AioImapException import pytest +import voluptuous as vol from homeassistant import config_entries, data_entry_flow from homeassistant.components.imap.const import ( @@ -397,6 +398,54 @@ async def test_key_options_in_options_form(hass: HomeAssistant) -> None: assert result2["errors"] == {"base": "already_configured"} +@pytest.mark.parametrize( + ("advanced_options", "assert_result"), + [ + ({"max_message_size": "8192"}, data_entry_flow.FlowResultType.CREATE_ENTRY), + ({"max_message_size": "1024"}, data_entry_flow.FlowResultType.FORM), + ({"max_message_size": "65536"}, data_entry_flow.FlowResultType.FORM), + ], +) +async def test_advanced_options_form( + hass: HomeAssistant, + advanced_options: dict[str, str], + assert_result: data_entry_flow.FlowResultType, +) -> None: + """Test we show the advanced options.""" + + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + + result = await hass.config_entries.options.async_init( + entry.entry_id, + context={"source": config_entries.SOURCE_USER, "show_advanced_options": True}, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "init" + + new_config = MOCK_OPTIONS.copy() + new_config.update(advanced_options) + + try: + with patch( + "homeassistant.components.imap.config_flow.connect_to_server" + ) as mock_client: + mock_client.return_value.search.return_value = ("OK", [b""]) + # Option update should fail if FlowResultType.FORM is expected + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], new_config + ) + assert result2["type"] == assert_result + # Check if entry was updated + for key, value in new_config.items(): + assert str(entry.data[key]) == value + except vol.MultipleInvalid: + # Check if form was expected with these options + 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( diff --git a/tests/components/imap/test_init.py b/tests/components/imap/test_init.py index 8f00cf395d2b..26c0325a50c2 100644 --- a/tests/components/imap/test_init.py +++ b/tests/components/imap/test_init.py @@ -446,3 +446,62 @@ async def test_reset_last_message( # One new event assert len(event_called) == 2 + + +@pytest.mark.parametrize("imap_search", [TEST_SEARCH_RESPONSE]) +@pytest.mark.parametrize( + "imap_fetch", [(TEST_FETCH_RESPONSE_TEXT_PLAIN)], ids=["plain"] +) +@pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) +@patch("homeassistant.components.imap.coordinator.MAX_EVENT_DATA_BYTES", 500) +async def test_event_skipped_message_too_large( + hass: HomeAssistant, mock_imap_protocol: MagicMock, caplog: pytest.LogCaptureFixture +) -> None: + """Test skipping event when message is to large.""" + event_called = async_capture_events(hass, "imap_content") + + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + # Make sure we have had one update (when polling) + async_fire_time_changed(hass, utcnow() + timedelta(seconds=5)) + await hass.async_block_till_done() + state = hass.states.get("sensor.imap_email_email_com") + # We should have received one message + assert state is not None + assert state.state == "1" + assert len(event_called) == 0 + assert "Custom imap_content event skipped" in caplog.text + + +@pytest.mark.parametrize("imap_search", [TEST_SEARCH_RESPONSE]) +@pytest.mark.parametrize( + "imap_fetch", [(TEST_FETCH_RESPONSE_TEXT_PLAIN)], ids=["plain"] +) +@pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) +async def test_message_is_truncated( + hass: HomeAssistant, mock_imap_protocol: MagicMock, caplog: pytest.LogCaptureFixture +) -> None: + """Test truncating message text in event data.""" + event_called = async_capture_events(hass, "imap_content") + + config = MOCK_CONFIG.copy() + + # Mock the max message size to test it is truncated + config["max_message_size"] = 3 + config_entry = MockConfigEntry(domain=DOMAIN, data=config) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + # Make sure we have had one update (when polling) + async_fire_time_changed(hass, utcnow() + timedelta(seconds=5)) + await hass.async_block_till_done() + state = hass.states.get("sensor.imap_email_email_com") + # We should have received one message + assert state is not None + assert state.state == "1" + assert len(event_called) == 1 + + event_data = event_called[0].data + assert len(event_data["text"]) == 3 From ec12d9a197a0cfdaba76e9422b3611ca226e1f12 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Mon, 22 May 2023 06:20:01 -0400 Subject: [PATCH 434/822] Improve test coverage for zwave_js (#93262) --- tests/components/zwave_js/test_helpers.py | 24 +++++++++++++ tests/components/zwave_js/test_humidifier.py | 37 ++++++++++++++++++++ tests/components/zwave_js/test_update.py | 16 +++++++++ 3 files changed, 77 insertions(+) create mode 100644 tests/components/zwave_js/test_helpers.py diff --git a/tests/components/zwave_js/test_helpers.py b/tests/components/zwave_js/test_helpers.py new file mode 100644 index 000000000000..aaa2907d30a3 --- /dev/null +++ b/tests/components/zwave_js/test_helpers.py @@ -0,0 +1,24 @@ +"""Test the Z-Wave JS helpers module.""" +from homeassistant.components.zwave_js.helpers import ( + async_get_node_status_sensor_entity_id, + async_get_nodes_from_area_id, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import area_registry as ar, device_registry as dr + + +async def test_async_get_node_status_sensor_entity_id(hass: HomeAssistant) -> None: + """Test async_get_node_status_sensor_entity_id for non zwave_js device.""" + dev_reg = dr.async_get(hass) + device = dev_reg.async_get_or_create( + config_entry_id="123", + identifiers={("test", "test")}, + ) + assert async_get_node_status_sensor_entity_id(hass, device.id) is None + + +async def test_async_get_nodes_from_area_id(hass: HomeAssistant) -> None: + """Test async_get_nodes_from_area_id.""" + area_reg = ar.async_get(hass) + area = area_reg.async_create("test") + assert not async_get_nodes_from_area_id(hass, area.id) diff --git a/tests/components/zwave_js/test_humidifier.py b/tests/components/zwave_js/test_humidifier.py index 4dca2754c6fd..23e2dc683149 100644 --- a/tests/components/zwave_js/test_humidifier.py +++ b/tests/components/zwave_js/test_humidifier.py @@ -20,6 +20,7 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_OFF, STATE_ON, + STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant @@ -914,3 +915,39 @@ async def test_dehumidifier( "property": "mode", } assert args["value"] == int(HumidityControlMode.DEHUMIDIFY) + + # Test setting value to None + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 68, + "args": { + "commandClassName": "Humidity Control Mode", + "commandClass": CommandClass.HUMIDITY_CONTROL_MODE, + "endpoint": 0, + "property": "mode", + "propertyName": "mode", + "newValue": None, + "prevValue": int(HumidityControlMode.OFF), + }, + }, + ) + node.receive_event(event) + + state = hass.states.get(HUMIDIFIER_ADC_T3000_ENTITY) + + assert state + assert state.state == STATE_UNKNOWN + + client.async_send_command.reset_mock() + + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: HUMIDIFIER_ADC_T3000_ENTITY}, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 0 diff --git a/tests/components/zwave_js/test_update.py b/tests/components/zwave_js/test_update.py index 36f16d0b5025..1a783f06bea6 100644 --- a/tests/components/zwave_js/test_update.py +++ b/tests/components/zwave_js/test_update.py @@ -776,3 +776,19 @@ async def test_update_entity_full_restore_data_no_update_available( assert state.state == STATE_OFF assert state.attributes[ATTR_SKIPPED_VERSION] is None assert state.attributes[ATTR_LATEST_VERSION] == "10.7" + + +async def test_update_entity_unload_asleep_node( + hass: HomeAssistant, client, wallmote_central_scene, integration +) -> None: + """Test unloading config entry after attempting an update for an asleep node.""" + assert len(client.async_send_command.call_args_list) == 0 + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=1)) + await hass.async_block_till_done() + + assert len(client.async_send_command.call_args_list) == 0 + assert len(wallmote_central_scene._listeners["wake up"]) == 2 + + await hass.config_entries.async_unload(integration.entry_id) + assert len(wallmote_central_scene._listeners["wake up"]) == 0 From 3d6d650a16ae7bcb6c8f369bc766b8c257a7b230 Mon Sep 17 00:00:00 2001 From: David Knowles Date: Mon, 22 May 2023 09:11:32 -0400 Subject: [PATCH 435/822] Move Hydrawise constants into const.py (#93357) --- .../components/hydrawise/__init__.py | 31 +++++++------------ .../components/hydrawise/binary_sensor.py | 9 ++---- homeassistant/components/hydrawise/const.py | 20 ++++++++++++ homeassistant/components/hydrawise/sensor.py | 11 +++---- homeassistant/components/hydrawise/switch.py | 10 +++--- 5 files changed, 42 insertions(+), 39 deletions(-) create mode 100644 homeassistant/components/hydrawise/const.py diff --git a/homeassistant/components/hydrawise/__init__.py b/homeassistant/components/hydrawise/__init__.py index a99c19cd46a1..8f1217e25a11 100644 --- a/homeassistant/components/hydrawise/__init__.py +++ b/homeassistant/components/hydrawise/__init__.py @@ -1,6 +1,4 @@ """Support for Hydrawise cloud.""" -from datetime import timedelta -import logging from hydrawiser.core import Hydrawiser from requests.exceptions import ConnectTimeout, HTTPError @@ -15,22 +13,15 @@ from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.event import track_time_interval from homeassistant.helpers.typing import ConfigType -_LOGGER = logging.getLogger(__name__) - -ALLOWED_WATERING_TIME = [5, 10, 15, 30, 45, 60] - -CONF_WATERING_TIME = "watering_minutes" - -NOTIFICATION_ID = "hydrawise_notification" -NOTIFICATION_TITLE = "Hydrawise Setup" - -DATA_HYDRAWISE = "hydrawise" -DOMAIN = "hydrawise" -DEFAULT_WATERING_TIME = 15 - -SCAN_INTERVAL = timedelta(seconds=120) - -SIGNAL_UPDATE_HYDRAWISE = "hydrawise_update" +from .const import ( + DATA_HYDRAWISE, + DOMAIN, + LOGGER, + NOTIFICATION_ID, + NOTIFICATION_TITLE, + SCAN_INTERVAL, + SIGNAL_UPDATE_HYDRAWISE, +) CONFIG_SCHEMA = vol.Schema( { @@ -55,7 +46,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: hydrawise = Hydrawiser(user_token=access_token) hass.data[DATA_HYDRAWISE] = HydrawiseHub(hydrawise) except (ConnectTimeout, HTTPError) as ex: - _LOGGER.error("Unable to connect to Hydrawise cloud service: %s", str(ex)) + LOGGER.error("Unable to connect to Hydrawise cloud service: %s", str(ex)) persistent_notification.create( hass, f"Error: {ex}
You will need to restart hass after fixing.", @@ -66,7 +57,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: def hub_refresh(event_time): """Call Hydrawise hub to refresh information.""" - _LOGGER.debug("Updating Hydrawise Hub component") + LOGGER.debug("Updating Hydrawise Hub component") hass.data[DATA_HYDRAWISE].data.update_controller_info() dispatcher_send(hass, SIGNAL_UPDATE_HYDRAWISE) diff --git a/homeassistant/components/hydrawise/binary_sensor.py b/homeassistant/components/hydrawise/binary_sensor.py index 3b496355a56b..832820fbcc75 100644 --- a/homeassistant/components/hydrawise/binary_sensor.py +++ b/homeassistant/components/hydrawise/binary_sensor.py @@ -1,8 +1,6 @@ """Support for Hydrawise sprinkler binary sensors.""" from __future__ import annotations -import logging - import voluptuous as vol from homeassistant.components.binary_sensor import ( @@ -17,9 +15,8 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DATA_HYDRAWISE, HydrawiseEntity - -_LOGGER = logging.getLogger(__name__) +from . import HydrawiseEntity +from .const import DATA_HYDRAWISE, LOGGER BINARY_SENSOR_STATUS = BinarySensorEntityDescription( key="status", @@ -82,7 +79,7 @@ class HydrawiseBinarySensor(HydrawiseEntity, BinarySensorEntity): def update(self) -> None: """Get the latest data and updates the state.""" - _LOGGER.debug("Updating Hydrawise binary sensor: %s", self.name) + LOGGER.debug("Updating Hydrawise binary sensor: %s", self.name) mydata = self.hass.data[DATA_HYDRAWISE].data if self.entity_description.key == "status": self._attr_is_on = mydata.status == "All good!" diff --git a/homeassistant/components/hydrawise/const.py b/homeassistant/components/hydrawise/const.py new file mode 100644 index 000000000000..5a046530f014 --- /dev/null +++ b/homeassistant/components/hydrawise/const.py @@ -0,0 +1,20 @@ +"""Constants for the Hydrawise integration.""" + +from datetime import timedelta +import logging + +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" + +DATA_HYDRAWISE = "hydrawise" +DOMAIN = "hydrawise" +DEFAULT_WATERING_TIME = 15 + +SCAN_INTERVAL = timedelta(seconds=120) + +SIGNAL_UPDATE_HYDRAWISE = "hydrawise_update" diff --git a/homeassistant/components/hydrawise/sensor.py b/homeassistant/components/hydrawise/sensor.py index 3114a50673fa..0b765d5d338f 100644 --- a/homeassistant/components/hydrawise/sensor.py +++ b/homeassistant/components/hydrawise/sensor.py @@ -1,8 +1,6 @@ """Support for Hydrawise sprinkler sensors.""" from __future__ import annotations -import logging - import voluptuous as vol from homeassistant.components.sensor import ( @@ -18,9 +16,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt -from . import DATA_HYDRAWISE, HydrawiseEntity - -_LOGGER = logging.getLogger(__name__) +from . import HydrawiseEntity +from .const import DATA_HYDRAWISE, LOGGER SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( @@ -76,7 +73,7 @@ class HydrawiseSensor(HydrawiseEntity, SensorEntity): def update(self) -> None: """Get the latest data and updates the states.""" mydata = self.hass.data[DATA_HYDRAWISE].data - _LOGGER.debug("Updating Hydrawise sensor: %s", self.name) + LOGGER.debug("Updating Hydrawise sensor: %s", self.name) relay_data = mydata.relays[self.data["relay"] - 1] if self.entity_description.key == "watering_time": if relay_data["timestr"] == "Now": @@ -85,7 +82,7 @@ class HydrawiseSensor(HydrawiseEntity, SensorEntity): self._attr_native_value = 0 else: # _sensor_type == 'next_cycle' next_cycle = min(relay_data["time"], TWO_YEAR_SECONDS) - _LOGGER.debug("New cycle time: %s", next_cycle) + LOGGER.debug("New cycle time: %s", next_cycle) self._attr_native_value = dt.utc_from_timestamp( dt.as_timestamp(dt.now()) + next_cycle ) diff --git a/homeassistant/components/hydrawise/switch.py b/homeassistant/components/hydrawise/switch.py index cc12638f3295..f075b334cd51 100644 --- a/homeassistant/components/hydrawise/switch.py +++ b/homeassistant/components/hydrawise/switch.py @@ -1,7 +1,6 @@ """Support for Hydrawise cloud switches.""" from __future__ import annotations -import logging from typing import Any import voluptuous as vol @@ -18,16 +17,15 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import ( +from . import HydrawiseEntity +from .const import ( ALLOWED_WATERING_TIME, CONF_WATERING_TIME, DATA_HYDRAWISE, DEFAULT_WATERING_TIME, - HydrawiseEntity, + LOGGER, ) -_LOGGER = logging.getLogger(__name__) - SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( SwitchEntityDescription( key="auto_watering", @@ -108,7 +106,7 @@ class HydrawiseSwitch(HydrawiseEntity, SwitchEntity): """Update device state.""" relay_data = self.data["relay"] - 1 mydata = self.hass.data[DATA_HYDRAWISE].data - _LOGGER.debug("Updating Hydrawise switch: %s", self.name) + LOGGER.debug("Updating Hydrawise switch: %s", self.name) if self.entity_description.key == "manual_watering": self._attr_is_on = mydata.relays[relay_data]["timestr"] == "Now" elif self.entity_description.key == "auto_watering": From ab8a13ae6be021a7313f6b3a35ce41eb1decc4e3 Mon Sep 17 00:00:00 2001 From: David Knowles Date: Mon, 22 May 2023 09:15:20 -0400 Subject: [PATCH 436/822] Add myself as a code owner of the Hydrawise integration (#93358) --- CODEOWNERS | 2 +- homeassistant/components/hydrawise/manifest.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index a870c7dea3cc..406fa6e4f3cf 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -539,7 +539,7 @@ build.json @home-assistant/supervisor /tests/components/hunterdouglas_powerview/ @bdraco @kingy444 @trullock /homeassistant/components/hvv_departures/ @vigonotion /tests/components/hvv_departures/ @vigonotion -/homeassistant/components/hydrawise/ @ptcryan +/homeassistant/components/hydrawise/ @dknowles2 @ptcryan /homeassistant/components/hyperion/ @dermotduffy /tests/components/hyperion/ @dermotduffy /homeassistant/components/ialarm/ @RyuzakiKK diff --git a/homeassistant/components/hydrawise/manifest.json b/homeassistant/components/hydrawise/manifest.json index 17b27b059419..2489317a6a23 100644 --- a/homeassistant/components/hydrawise/manifest.json +++ b/homeassistant/components/hydrawise/manifest.json @@ -1,7 +1,7 @@ { "domain": "hydrawise", "name": "Hunter Hydrawise", - "codeowners": ["@ptcryan"], + "codeowners": ["@dknowles2", "@ptcryan"], "documentation": "https://www.home-assistant.io/integrations/hydrawise", "iot_class": "cloud_polling", "loggers": ["hydrawiser"], From 6df030a4557f1187061464225231b5115e744663 Mon Sep 17 00:00:00 2001 From: Dmitry Vlasov Date: Mon, 22 May 2023 16:20:18 +0300 Subject: [PATCH 437/822] Update zwave-me-ws version to 0.4.3 (#93348) Up zwave-me-ws to 0.4.3 --- homeassistant/components/zwave_me/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_me/manifest.json b/homeassistant/components/zwave_me/manifest.json index 1a4d9cccbe4b..5870874efa80 100644 --- a/homeassistant/components/zwave_me/manifest.json +++ b/homeassistant/components/zwave_me/manifest.json @@ -6,7 +6,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zwave_me", "iot_class": "local_push", - "requirements": ["zwave_me_ws==0.4.2", "url-normalize==1.4.3"], + "requirements": ["zwave_me_ws==0.4.3", "url-normalize==1.4.3"], "zeroconf": [ { "type": "_hap._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 733c2815c0b8..50543d1a49ba 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2757,4 +2757,4 @@ zm-py==0.5.2 zwave-js-server-python==0.48.1 # homeassistant.components.zwave_me -zwave_me_ws==0.4.2 +zwave_me_ws==0.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7e61e0d53f38..68dd5812545b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2006,4 +2006,4 @@ zigpy==0.55.0 zwave-js-server-python==0.48.1 # homeassistant.components.zwave_me -zwave_me_ws==0.4.2 +zwave_me_ws==0.4.3 From f2899a19c151c586d14be5cd03354e09985e061b Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 22 May 2023 15:27:33 +0200 Subject: [PATCH 438/822] Deprecate counter.configure service (#93343) --- homeassistant/components/counter/__init__.py | 12 +++++ .../components/counter/services.yaml | 48 ------------------- homeassistant/components/counter/strings.json | 13 +++++ tests/components/counter/test_init.py | 11 ++++- 4 files changed, 34 insertions(+), 50 deletions(-) diff --git a/homeassistant/components/counter/__init__.py b/homeassistant/components/counter/__init__.py index a77e43408827..91a90c1aa502 100644 --- a/homeassistant/components/counter/__init__.py +++ b/homeassistant/components/counter/__init__.py @@ -18,6 +18,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import collection import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType @@ -291,6 +292,17 @@ class Counter(collection.CollectionEntity, RestoreEntity): @callback def async_configure(self, **kwargs) -> None: """Change the counter's settings with a service.""" + async_create_issue( + self.hass, + DOMAIN, + "deprecated_configure_service", + breaks_in_ha_version="2023.8.0", + is_fixable=True, + is_persistent=True, + severity=IssueSeverity.WARNING, + translation_key="deprecated_configure_service", + ) + new_state = kwargs.pop(VALUE, self._state) self._config = {**self._config, **kwargs} self._state = self.compute_next_state(new_state) diff --git a/homeassistant/components/counter/services.yaml b/homeassistant/components/counter/services.yaml index d94d05dd7bd2..835d39c9d2e0 100644 --- a/homeassistant/components/counter/services.yaml +++ b/homeassistant/components/counter/services.yaml @@ -37,51 +37,3 @@ set_value: min: 0 max: 9223372036854775807 mode: box - -configure: - name: Configure - description: Change counter parameters. - target: - entity: - domain: counter - fields: - minimum: - name: Minimum - description: New minimum value for the counter or None to remove minimum. - selector: - number: - min: -9223372036854775807 - max: 9223372036854775807 - mode: box - maximum: - name: Maximum - description: New maximum value for the counter or None to remove maximum. - selector: - number: - min: -9223372036854775807 - max: 9223372036854775807 - mode: box - step: - name: Step - description: New value for step. - selector: - number: - min: 1 - max: 9223372036854775807 - mode: box - initial: - name: Initial - description: New value for initial. - selector: - number: - min: 0 - max: 9223372036854775807 - mode: box - value: - name: Value - description: New state value. - selector: - number: - min: 0 - max: 9223372036854775807 - mode: box diff --git a/homeassistant/components/counter/strings.json b/homeassistant/components/counter/strings.json index 548d1554080d..095925946593 100644 --- a/homeassistant/components/counter/strings.json +++ b/homeassistant/components/counter/strings.json @@ -25,5 +25,18 @@ } } } + }, + "issues": { + "deprecated_configure_service": { + "title": "The counter configure service is being removed", + "fix_flow": { + "step": { + "confirm": { + "title": "The counter configure service is being removed", + "description": "The counter service `counter.configure` is being removed and use of it has been detected. If you want to change the current value of a counter, use the new `counter.set_value` service instead.\n\nPlease remove the use of this service from your automations and scripts and select **submit** to close this issue." + } + } + } + } } } diff --git a/tests/components/counter/test_init.py b/tests/components/counter/test_init.py index 5c6dbba71ce8..6730b4dec843 100644 --- a/tests/components/counter/test_init.py +++ b/tests/components/counter/test_init.py @@ -24,7 +24,7 @@ from homeassistant.components.counter import ( ) from homeassistant.const import ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, ATTR_ICON, ATTR_NAME from homeassistant.core import Context, CoreState, HomeAssistant, State -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.setup import async_setup_component from .common import async_decrement, async_increment, async_reset @@ -443,7 +443,9 @@ async def test_counter_max(hass: HomeAssistant, hass_admin_user: MockUser) -> No assert state2.state == "-1" -async def test_configure(hass: HomeAssistant, hass_admin_user: MockUser) -> None: +async def test_configure( + hass: HomeAssistant, hass_admin_user: MockUser, issue_registry: ir.IssueRegistry +) -> None: """Test that setting values through configure works.""" assert await async_setup_component( hass, "counter", {"counter": {"test": {"maximum": "10", "initial": "10"}}} @@ -468,6 +470,11 @@ async def test_configure(hass: HomeAssistant, hass_admin_user: MockUser) -> None assert state.state == "0" assert state.attributes.get("maximum") == 0 + # Ensure an issue is raised for the use of this deprecated service + assert issue_registry.async_get_issue( + domain=DOMAIN, issue_id="deprecated_configure_service" + ) + # disable max await hass.services.async_call( "counter", From 7537667e530565b20bee6f01e535bd15d637707b Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 22 May 2023 15:27:44 +0200 Subject: [PATCH 439/822] Adjust counter to not restore configuration parameters (#93344) --- homeassistant/components/counter/__init__.py | 4 ---- tests/components/counter/test_init.py | 11 ----------- 2 files changed, 15 deletions(-) diff --git a/homeassistant/components/counter/__init__.py b/homeassistant/components/counter/__init__.py index 91a90c1aa502..d2834b8991b7 100644 --- a/homeassistant/components/counter/__init__.py +++ b/homeassistant/components/counter/__init__.py @@ -245,10 +245,6 @@ class Counter(collection.CollectionEntity, RestoreEntity): and (state := await self.async_get_last_state()) is not None ): self._state = self.compute_next_state(int(state.state)) - self._config[CONF_INITIAL] = state.attributes.get(ATTR_INITIAL) - self._config[CONF_MAXIMUM] = state.attributes.get(ATTR_MAXIMUM) - self._config[CONF_MINIMUM] = state.attributes.get(ATTR_MINIMUM) - self._config[CONF_STEP] = state.attributes.get(ATTR_STEP) @callback def async_decrement(self) -> None: diff --git a/tests/components/counter/test_init.py b/tests/components/counter/test_init.py index 6730b4dec843..097102a341e1 100644 --- a/tests/components/counter/test_init.py +++ b/tests/components/counter/test_init.py @@ -307,14 +307,11 @@ async def test_initial_state_overrules_restore_state(hass: HomeAssistant) -> Non async def test_restore_state_overrules_initial_state(hass: HomeAssistant) -> None: """Ensure states are restored on startup.""" - attr = {"initial": 6, "minimum": 1, "maximum": 8, "step": 2} - mock_restore_cache( hass, ( State("counter.test1", "11"), State("counter.test2", "-22"), - State("counter.test3", "5", attr), ), ) @@ -332,14 +329,6 @@ async def test_restore_state_overrules_initial_state(hass: HomeAssistant) -> Non assert state assert int(state.state) == -22 - state = hass.states.get("counter.test3") - assert state - assert int(state.state) == 5 - assert state.attributes.get("initial") == 6 - assert state.attributes.get("minimum") == 1 - assert state.attributes.get("maximum") == 8 - assert state.attributes.get("step") == 2 - async def test_no_initial_state_and_no_restore_state(hass: HomeAssistant) -> None: """Ensure that entity is create without initial and restore feature.""" From 9eca23494892d2a6e04817d2154e58340ecf8edb Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 22 May 2023 15:45:29 +0200 Subject: [PATCH 440/822] Update Home Assistant base image to 2023.05.0 (#93350) --- build.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/build.yaml b/build.yaml index ab3bf3e67773..11b60a662952 100644 --- a/build.yaml +++ b/build.yaml @@ -1,11 +1,11 @@ image: homeassistant/{arch}-homeassistant shadow_repository: ghcr.io/home-assistant build_from: - aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2023.04.2 - armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2023.04.2 - armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2023.04.2 - amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2023.04.2 - i386: ghcr.io/home-assistant/i386-homeassistant-base:2023.04.2 + aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2023.05.0 + armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2023.05.0 + armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2023.05.0 + amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2023.05.0 + i386: ghcr.io/home-assistant/i386-homeassistant-base:2023.05.0 codenotary: signer: notary@home-assistant.io base_image: notary@home-assistant.io From 4d44d60e734069d0e9c1c282bc308adbd4ed9a4b Mon Sep 17 00:00:00 2001 From: David Knowles Date: Mon, 22 May 2023 09:47:32 -0400 Subject: [PATCH 441/822] Move HydrawiseEntity into entity.py (#93359) --- .../components/hydrawise/__init__.py | 35 +----------------- .../components/hydrawise/binary_sensor.py | 2 +- homeassistant/components/hydrawise/entity.py | 37 +++++++++++++++++++ homeassistant/components/hydrawise/sensor.py | 2 +- homeassistant/components/hydrawise/switch.py | 2 +- 5 files changed, 42 insertions(+), 36 deletions(-) create mode 100644 homeassistant/components/hydrawise/entity.py diff --git a/homeassistant/components/hydrawise/__init__.py b/homeassistant/components/hydrawise/__init__.py index 8f1217e25a11..ba561a14f82d 100644 --- a/homeassistant/components/hydrawise/__init__.py +++ b/homeassistant/components/hydrawise/__init__.py @@ -6,10 +6,9 @@ import voluptuous as vol from homeassistant.components import persistent_notification from homeassistant.const import CONF_ACCESS_TOKEN, CONF_SCAN_INTERVAL -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send -from homeassistant.helpers.entity import Entity, EntityDescription +from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.event import track_time_interval from homeassistant.helpers.typing import ConfigType @@ -73,33 +72,3 @@ class HydrawiseHub: def __init__(self, data): """Initialize the entity.""" self.data = data - - -class HydrawiseEntity(Entity): - """Entity class for Hydrawise devices.""" - - _attr_attribution = "Data provided by hydrawise.com" - - def __init__(self, data, description: EntityDescription) -> None: - """Initialize the Hydrawise entity.""" - self.entity_description = description - self.data = data - self._attr_name = f"{self.data['name']} {description.name}" - - async def async_added_to_hass(self): - """Register callbacks.""" - self.async_on_remove( - async_dispatcher_connect( - self.hass, SIGNAL_UPDATE_HYDRAWISE, self._update_callback - ) - ) - - @callback - def _update_callback(self): - """Call update method.""" - self.async_schedule_update_ha_state(True) - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return {"identifier": self.data.get("relay")} diff --git a/homeassistant/components/hydrawise/binary_sensor.py b/homeassistant/components/hydrawise/binary_sensor.py index 832820fbcc75..93594d714363 100644 --- a/homeassistant/components/hydrawise/binary_sensor.py +++ b/homeassistant/components/hydrawise/binary_sensor.py @@ -15,8 +15,8 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import HydrawiseEntity from .const import DATA_HYDRAWISE, LOGGER +from .entity import HydrawiseEntity BINARY_SENSOR_STATUS = BinarySensorEntityDescription( key="status", diff --git a/homeassistant/components/hydrawise/entity.py b/homeassistant/components/hydrawise/entity.py new file mode 100644 index 000000000000..5c54c1ee5808 --- /dev/null +++ b/homeassistant/components/hydrawise/entity.py @@ -0,0 +1,37 @@ +"""Base classes for Hydrawise entities.""" + +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity, EntityDescription + +from .const import SIGNAL_UPDATE_HYDRAWISE + + +class HydrawiseEntity(Entity): + """Entity class for Hydrawise devices.""" + + _attr_attribution = "Data provided by hydrawise.com" + + def __init__(self, data, description: EntityDescription) -> None: + """Initialize the Hydrawise entity.""" + self.entity_description = description + self.data = data + self._attr_name = f"{self.data['name']} {description.name}" + + async def async_added_to_hass(self): + """Register callbacks.""" + self.async_on_remove( + async_dispatcher_connect( + self.hass, SIGNAL_UPDATE_HYDRAWISE, self._update_callback + ) + ) + + @callback + def _update_callback(self): + """Call update method.""" + self.async_schedule_update_ha_state(True) + + @property + def extra_state_attributes(self): + """Return the state attributes.""" + return {"identifier": self.data.get("relay")} diff --git a/homeassistant/components/hydrawise/sensor.py b/homeassistant/components/hydrawise/sensor.py index 0b765d5d338f..2cec1309ec95 100644 --- a/homeassistant/components/hydrawise/sensor.py +++ b/homeassistant/components/hydrawise/sensor.py @@ -16,8 +16,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt -from . import HydrawiseEntity from .const import DATA_HYDRAWISE, LOGGER +from .entity import HydrawiseEntity SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( diff --git a/homeassistant/components/hydrawise/switch.py b/homeassistant/components/hydrawise/switch.py index f075b334cd51..ac9b0d27025c 100644 --- a/homeassistant/components/hydrawise/switch.py +++ b/homeassistant/components/hydrawise/switch.py @@ -17,7 +17,6 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import HydrawiseEntity from .const import ( ALLOWED_WATERING_TIME, CONF_WATERING_TIME, @@ -25,6 +24,7 @@ from .const import ( DEFAULT_WATERING_TIME, LOGGER, ) +from .entity import HydrawiseEntity SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( SwitchEntityDescription( From a81fe517c1dd0d0674ce179c0384dce7adc9aebd Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 22 May 2023 15:49:18 +0200 Subject: [PATCH 442/822] Make mqtt device_tracker state_topic optional when json_attribute_topic is used (#93322) * Make device_tracker state_topic optional * No state_topic needed --- .../components/mqtt/device_tracker.py | 32 +++++++++++++++---- tests/components/mqtt/test_device_tracker.py | 8 ++--- 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/mqtt/device_tracker.py b/homeassistant/components/mqtt/device_tracker.py index b55c37546961..dbe7188da7de 100644 --- a/homeassistant/components/mqtt/device_tracker.py +++ b/homeassistant/components/mqtt/device_tracker.py @@ -28,17 +28,18 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import subscription -from .config import MQTT_RO_SCHEMA +from .config import MQTT_BASE_SCHEMA from .const import CONF_PAYLOAD_RESET, CONF_QOS, CONF_STATE_TOPIC from .debug_info import log_messages from .mixins import ( + CONF_JSON_ATTRS_TOPIC, MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper, warn_for_legacy_schema, ) from .models import MqttValueTemplate, ReceiveMessage, ReceivePayloadType -from .util import get_mqtt_data +from .util import get_mqtt_data, valid_subscribe_topic CONF_PAYLOAD_HOME = "payload_home" CONF_PAYLOAD_NOT_HOME = "payload_not_home" @@ -47,8 +48,20 @@ CONF_SOURCE_TYPE = "source_type" DEFAULT_PAYLOAD_RESET = "None" DEFAULT_SOURCE_TYPE = SourceType.GPS -PLATFORM_SCHEMA_MODERN = MQTT_RO_SCHEMA.extend( + +def valid_config(config: ConfigType) -> ConfigType: + """Check if there is a state topic or json_attributes_topic.""" + if CONF_STATE_TOPIC not in config and CONF_JSON_ATTRS_TOPIC not in config: + raise vol.MultipleInvalid( + f"Invalid device tracker config, missing {CONF_STATE_TOPIC} or {CONF_JSON_ATTRS_TOPIC}, got: {config}" + ) + return config + + +PLATFORM_SCHEMA_MODERN_BASE = MQTT_BASE_SCHEMA.extend( { + vol.Optional(CONF_STATE_TOPIC): valid_subscribe_topic, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_PAYLOAD_HOME, default=STATE_HOME): cv.string, vol.Optional(CONF_PAYLOAD_NOT_HOME, default=STATE_NOT_HOME): cv.string, @@ -56,10 +69,14 @@ PLATFORM_SCHEMA_MODERN = MQTT_RO_SCHEMA.extend( vol.Optional(CONF_SOURCE_TYPE, default=DEFAULT_SOURCE_TYPE): vol.In( SOURCE_TYPES ), - } + }, ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) +PLATFORM_SCHEMA_MODERN = vol.All(PLATFORM_SCHEMA_MODERN_BASE, valid_config) -DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA) + +DISCOVERY_SCHEMA = vol.All( + PLATFORM_SCHEMA_MODERN_BASE.extend({}, extra=vol.REMOVE_EXTRA), valid_config +) # Configuring MQTT Device Trackers under the device_tracker platform key was deprecated # in HA Core 2022.6 @@ -138,12 +155,15 @@ class MqttDeviceTracker(MqttEntity, TrackerEntity): 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 self._sub_state = subscription.async_prepare_subscribe_topics( self.hass, self._sub_state, { "state_topic": { - "topic": self._config[CONF_STATE_TOPIC], + "topic": state_topic, "msg_callback": message_received, "qos": self._config[CONF_QOS], } diff --git a/tests/components/mqtt/test_device_tracker.py b/tests/components/mqtt/test_device_tracker.py index 182a5a0673d9..3793902258d6 100644 --- a/tests/components/mqtt/test_device_tracker.py +++ b/tests/components/mqtt/test_device_tracker.py @@ -403,16 +403,12 @@ async def test_setting_device_tracker_location_via_lat_lon_message( mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: - """Test the setting of the latitude and longitude via MQTT.""" + """Test the setting of the latitude and longitude via MQTT without state topic.""" await mqtt_mock_entry() async_fire_mqtt_message( hass, "homeassistant/device_tracker/bla/config", - "{ " - '"name": "test", ' - '"state_topic": "test-topic", ' - '"json_attributes_topic": "attributes-topic" ' - "}", + '{ "name": "test", "json_attributes_topic": "attributes-topic"}', ) await hass.async_block_till_done() From 5d02be5d7f166e7a32f73d14f7e400c9c41da051 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 May 2023 10:39:56 -0500 Subject: [PATCH 443/822] Fix august configuration url with Yale Home brand (#93361) * Fix august configuration url with Yale Home brand changelog: https://github.com/bdraco/yalexs/compare/v1.4.6...v1.5.0 * bump --- homeassistant/components/august/__init__.py | 7 ++++++- homeassistant/components/august/entity.py | 3 ++- homeassistant/components/august/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 11 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/august/__init__.py b/homeassistant/components/august/__init__.py index f4e048ecf16c..8be7d8dd2d19 100644 --- a/homeassistant/components/august/__init__.py +++ b/homeassistant/components/august/__init__.py @@ -142,6 +142,11 @@ class AugustData(AugustSubscriberMixin): self._house_ids: set[str] = set() self._pubnub_unsub: CALLBACK_TYPE | None = None + @property + def brand(self) -> str: + """Brand of the device.""" + return self._config_entry.data.get(CONF_BRAND, DEFAULT_BRAND) + async def async_setup(self): """Async setup of august device data and activities.""" token = self._august_gateway.access_token @@ -194,7 +199,7 @@ class AugustData(AugustSubscriberMixin): self._pubnub_unsub = async_create_pubnub( user_data["UserID"], pubnub, - self._config_entry.data.get(CONF_BRAND, DEFAULT_BRAND), + self.brand, ) if self._locks_by_id: diff --git a/homeassistant/components/august/entity.py b/homeassistant/components/august/entity.py index 2f163469bfad..0b7a42267d84 100644 --- a/homeassistant/components/august/entity.py +++ b/homeassistant/components/august/entity.py @@ -3,6 +3,7 @@ from abc import abstractmethod from yalexs.doorbell import Doorbell from yalexs.lock import Lock +from yalexs.util import get_configuration_url from homeassistant.core import callback from homeassistant.helpers.entity import DeviceInfo, Entity @@ -30,7 +31,7 @@ class AugustEntityMixin(Entity): name=device.device_name, sw_version=self._detail.firmware_version, suggested_area=_remove_device_types(device.device_name, DEVICE_TYPES), - configuration_url="https://account.august.com", + configuration_url=get_configuration_url(data.brand), ) @property diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index 56965dff8508..eeaa5f6c6222 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.4.6", "yalexs-ble==2.1.17"] + "requirements": ["yalexs==1.5.1", "yalexs-ble==2.1.17"] } diff --git a/requirements_all.txt b/requirements_all.txt index 50543d1a49ba..c685edaa5b73 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2697,7 +2697,7 @@ yalesmartalarmclient==0.3.9 yalexs-ble==2.1.17 # homeassistant.components.august -yalexs==1.4.6 +yalexs==1.5.1 # homeassistant.components.yeelight yeelight==0.7.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 68dd5812545b..8719e908495c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1964,7 +1964,7 @@ yalesmartalarmclient==0.3.9 yalexs-ble==2.1.17 # homeassistant.components.august -yalexs==1.4.6 +yalexs==1.5.1 # homeassistant.components.yeelight yeelight==0.7.10 From d6997d8656fbc184a15b850d20f968d3df551254 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Mon, 22 May 2023 11:55:03 -0400 Subject: [PATCH 444/822] Improve test coverage for zwave_js.trigger (#93260) Improve coverage for zwave_js.trigger --- homeassistant/components/zwave_js/trigger.py | 7 +------ tests/components/zwave_js/test_trigger.py | 13 +++++++++++-- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/zwave_js/trigger.py b/homeassistant/components/zwave_js/trigger.py index f747c25c71bc..94cb05b1b20d 100644 --- a/homeassistant/components/zwave_js/trigger.py +++ b/homeassistant/components/zwave_js/trigger.py @@ -1,8 +1,6 @@ """Z-Wave JS trigger dispatcher.""" from __future__ import annotations -from typing import cast - from homeassistant.const import CONF_PLATFORM from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.helpers.trigger import ( @@ -33,10 +31,7 @@ async def async_validate_trigger_config( ) -> ConfigType: """Validate config.""" platform = _get_trigger_platform(config) - if hasattr(platform, "async_validate_trigger_config"): - return await platform.async_validate_trigger_config(hass, config) - - return cast(ConfigType, platform.TRIGGER_SCHEMA(config)) + return await platform.async_validate_trigger_config(hass, config) async def async_attach_trigger( diff --git a/tests/components/zwave_js/test_trigger.py b/tests/components/zwave_js/test_trigger.py index cbf68a55f5a3..9df8aa75f434 100644 --- a/tests/components/zwave_js/test_trigger.py +++ b/tests/components/zwave_js/test_trigger.py @@ -10,11 +10,14 @@ from zwave_js_server.model.node import Node from homeassistant.components import automation from homeassistant.components.zwave_js import DOMAIN from homeassistant.components.zwave_js.helpers import get_device_id -from homeassistant.components.zwave_js.trigger import async_validate_trigger_config +from homeassistant.components.zwave_js.trigger import ( + _get_trigger_platform, + async_validate_trigger_config, +) from homeassistant.components.zwave_js.triggers.trigger_helpers import ( async_bypass_dynamic_config_validation, ) -from homeassistant.const import SERVICE_RELOAD +from homeassistant.const import CONF_PLATFORM, SERVICE_RELOAD from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import async_get as async_get_dev_reg from homeassistant.setup import async_setup_component @@ -1100,3 +1103,9 @@ async def test_zwave_js_trigger_config_entry_unloaded( "event": "nvm convert progress", }, ) + + +def test_get_trigger_platform_failure() -> None: + """Test _get_trigger_platform.""" + with pytest.raises(ValueError): + _get_trigger_platform({CONF_PLATFORM: "zwave_js.invalid"}) From e1dd7118e0b17f64865e43ff84651aaae3054d67 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Mon, 22 May 2023 11:58:10 -0400 Subject: [PATCH 445/822] Refactor zwave_js.sensor and add test coverage (#93259) * Refactor zwave_js.sensor and add test coverage * use walrus * inherit config parameter class from list class * use walrus in more places * improve config parameter test --- homeassistant/components/zwave_js/sensor.py | 123 +++++--------------- tests/components/zwave_js/test_sensor.py | 99 ++++++++++++++++ 2 files changed, 125 insertions(+), 97 deletions(-) diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index f7fe5f52d791..313110169c3a 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -38,10 +38,10 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from .const import ( ATTR_METER_TYPE, @@ -311,11 +311,7 @@ async def async_setup_entry( entity_description = get_entity_description(data) - if info.platform_hint == "string_sensor": - entities.append( - ZWaveStringSensor(config_entry, driver, info, entity_description) - ) - elif info.platform_hint == "numeric_sensor": + if info.platform_hint == "numeric_sensor": entities.append( ZWaveNumericSensor( config_entry, @@ -340,12 +336,7 @@ async def async_setup_entry( ZWaveMeterSensor(config_entry, driver, info, entity_description) ) else: - LOGGER.warning( - "Sensor not implemented for %s/%s", - info.platform_hint, - info.primary_value.property_name, - ) - return + entities.append(ZwaveSensor(config_entry, driver, info, entity_description)) async_add_entities(entities) @@ -383,7 +374,7 @@ async def async_setup_entry( ) -class ZwaveSensorBase(ZWaveBaseEntity, SensorEntity): +class ZwaveSensor(ZWaveBaseEntity, SensorEntity): """Basic Representation of a Z-Wave sensor.""" def __init__( @@ -403,26 +394,25 @@ class ZwaveSensorBase(ZWaveBaseEntity, SensorEntity): self._attr_force_update = True self._attr_name = self.generate_name(include_value_name=True) - -class ZWaveStringSensor(ZwaveSensorBase): - """Representation of a Z-Wave String sensor.""" - @property - def native_value(self) -> str | None: + def native_value(self) -> StateType: """Return state of the sensor.""" - if self.info.primary_value.value is None: - return None - return str(self.info.primary_value.value) + key = str(self.info.primary_value.value) + if key not in self.info.primary_value.metadata.states: + return self.info.primary_value.value + return str(self.info.primary_value.metadata.states[key]) @property def native_unit_of_measurement(self) -> str | None: """Return unit of measurement the value is expressed in.""" + if (unit := super().native_unit_of_measurement) is not None: + return unit if self.info.primary_value.metadata.unit is None: return None return str(self.info.primary_value.metadata.unit) -class ZWaveNumericSensor(ZwaveSensorBase): +class ZWaveNumericSensor(ZwaveSensor): """Representation of a Z-Wave Numeric sensor.""" @callback @@ -439,18 +429,6 @@ class ZWaveNumericSensor(ZwaveSensorBase): return 0 return round(float(self.info.primary_value.value), 2) - @property - def native_unit_of_measurement(self) -> str | None: - """Return unit of measurement the value is expressed in.""" - if self.entity_description.native_unit_of_measurement is not None: - return self.entity_description.native_unit_of_measurement - if self._attr_native_unit_of_measurement is not None: - return self._attr_native_unit_of_measurement - if self.info.primary_value.metadata.unit is None: - return None - - return str(self.info.primary_value.metadata.unit) - class ZWaveMeterSensor(ZWaveNumericSensor): """Representation of a Z-Wave Meter CC sensor.""" @@ -458,21 +436,18 @@ class ZWaveMeterSensor(ZWaveNumericSensor): @property def extra_state_attributes(self) -> Mapping[str, int | str] | None: """Return extra state attributes.""" - if meter_type := get_meter_type(self.info.primary_value): - return { - ATTR_METER_TYPE: meter_type.value, - ATTR_METER_TYPE_NAME: meter_type.name, - } - return None + meter_type = get_meter_type(self.info.primary_value) + return { + ATTR_METER_TYPE: meter_type.value, + ATTR_METER_TYPE_NAME: meter_type.name, + } async def async_reset_meter( self, meter_type: int | None = None, value: int | None = None ) -> None: """Reset meter(s) on device.""" node = self.info.node - primary_value = self.info.primary_value - if (endpoint := primary_value.endpoint) is None: - raise HomeAssistantError("Missing endpoint on device.") + endpoint = self.info.primary_value.endpoint or 0 options = {} if meter_type is not None: options[RESET_METER_OPTION_TYPE] = meter_type @@ -485,35 +460,19 @@ class ZWaveMeterSensor(ZWaveNumericSensor): LOGGER.debug( "Meters on node %s endpoint %s reset with the following options: %s", node, - primary_value.endpoint, + endpoint, options, ) -class ZWaveListSensor(ZwaveSensorBase): - """Representation of a Z-Wave List sensor with multiple states.""" - - def __init__( - self, - config_entry: ConfigEntry, - driver: Driver, - info: ZwaveDiscoveryInfo, - entity_description: SensorEntityDescription, - unit_of_measurement: str | None = None, - ) -> None: - """Initialize a ZWaveListSensor entity.""" - super().__init__( - config_entry, driver, info, entity_description, unit_of_measurement - ) - - # Entity class attributes - self._attr_name = self.generate_name(include_value_name=True) +class ZWaveListSensor(ZwaveSensor): + """Representation of a Z-Wave Numeric sensor with multiple states.""" @property def device_class(self) -> SensorDeviceClass | None: """Return sensor device class.""" - if super().device_class is not None: - return super().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 @@ -525,16 +484,6 @@ class ZWaveListSensor(ZwaveSensorBase): return list(self.info.primary_value.metadata.states.values()) return None - @property - def native_value(self) -> str | None: - """Return state of the sensor.""" - if self.info.primary_value.value is None: - return None - key = str(self.info.primary_value.value) - if key not in self.info.primary_value.metadata.states: - return key - return str(self.info.primary_value.metadata.states[key]) - @property def extra_state_attributes(self) -> dict[str, str] | None: """Return the device specific state attributes.""" @@ -544,7 +493,7 @@ class ZWaveListSensor(ZwaveSensorBase): return {ATTR_VALUE: value} -class ZWaveConfigParameterSensor(ZwaveSensorBase): +class ZWaveConfigParameterSensor(ZWaveListSensor): """Representation of a Z-Wave config parameter sensor.""" def __init__( @@ -572,8 +521,8 @@ class ZWaveConfigParameterSensor(ZwaveSensorBase): @property def device_class(self) -> SensorDeviceClass | None: """Return sensor device class.""" - if super().device_class is not None: - return super().device_class + if (device_class := super(ZwaveSensor, self).device_class) is not None: + return device_class if ( self._primary_value.configuration_value_type == ConfigurationValueType.ENUMERATED @@ -581,26 +530,6 @@ class ZWaveConfigParameterSensor(ZwaveSensorBase): return SensorDeviceClass.ENUM return None - @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 native_value(self) -> str | None: - """Return state of the sensor.""" - if self.info.primary_value.value is None: - return None - key = str(self.info.primary_value.value) - if ( - self._primary_value.configuration_value_type == ConfigurationValueType.RANGE - or (key not in self.info.primary_value.metadata.states) - ): - return key - return str(self.info.primary_value.metadata.states[key]) - @property def extra_state_attributes(self) -> dict[str, str] | None: """Return the device specific state attributes.""" diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index e41158a5d1d3..d38466539e92 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -7,6 +7,7 @@ from zwave_js_server.event import Event from zwave_js_server.model.node import Node from homeassistant.components.sensor import ( + ATTR_OPTIONS, ATTR_STATE_CLASS, SensorDeviceClass, SensorStateClass, @@ -27,6 +28,7 @@ from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, PERCENTAGE, STATE_UNAVAILABLE, + STATE_UNKNOWN, EntityCategory, UnitOfElectricCurrent, UnitOfElectricPotential, @@ -103,6 +105,30 @@ async def test_numeric_sensor( assert ATTR_DEVICE_CLASS not in state.attributes assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT + event = Event( + "value updated", + { + "source": "node", + "event": "value updated", + "nodeId": express_controls_ezmultipli.node_id, + "args": { + "commandClassName": "Multilevel Sensor", + "commandClass": 49, + "endpoint": 0, + "property": "Illuminance", + "propertyName": "Illuminance", + "newValue": None, + "prevValue": 61, + }, + }, + ) + + express_controls_ezmultipli.receive_event(event) + await hass.async_block_till_done() + state = hass.states.get("sensor.hsm200_illuminance") + assert state + assert state.state == "0" + async def test_energy_sensors( hass: HomeAssistant, hank_binary_switch, integration @@ -165,6 +191,32 @@ async def test_disabled_notification_sensor( assert state.state == "Motion detection" assert state.attributes["value"] == 8 + event = Event( + "value updated", + { + "source": "node", + "event": "value updated", + "nodeId": multisensor_6.node_id, + "args": { + "commandClassName": "Notification", + "commandClass": 113, + "endpoint": 0, + "property": "Home Security", + "propertyKey": "Motion sensor status", + "newValue": None, + "prevValue": 0, + "propertyName": "Home Security", + "propertyKeyName": "Motion sensor status", + }, + }, + ) + + multisensor_6.receive_event(event) + await hass.async_block_till_done() + state = hass.states.get(NOTIFICATION_MOTION_SENSOR) + assert state + assert state.state == STATE_UNKNOWN + async def test_disabled_indcator_sensor( hass: HomeAssistant, climate_radio_thermostat_ct100_plus, integration @@ -187,6 +239,53 @@ async def test_config_parameter_sensor( assert entity_entry assert entity_entry.disabled + updated_entry = ent_reg.async_update_entity( + entity_entry.entity_id, **{"disabled_by": None} + ) + assert updated_entry != entity_entry + assert updated_entry.disabled is False + + # reload integration and check if entity is correctly there + await hass.config_entries.async_reload(integration.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(ID_LOCK_CONFIG_PARAMETER_SENSOR) + assert state + assert state.state == "Disable Away Manual Lock" + assert state.attributes[ATTR_VALUE] == 0 + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.ENUM + assert state.attributes[ATTR_OPTIONS] == [ + "Disable Away Manual Lock", + "Disable Away Auto Lock", + "Enable Away Manual Lock", + "Enable Away Auto Lock", + ] + + event = Event( + "value updated", + { + "source": "node", + "event": "value updated", + "nodeId": lock_id_lock_as_id150.node_id, + "args": { + "commandClassName": "Configuration", + "commandClass": 112, + "endpoint": 0, + "property": 1, + "newValue": None, + "prevValue": 0, + "propertyName": "Door lock mode", + }, + }, + ) + + lock_id_lock_as_id150.receive_event(event) + await hass.async_block_till_done() + state = hass.states.get(ID_LOCK_CONFIG_PARAMETER_SENSOR) + assert state + assert state.state == STATE_UNKNOWN + assert ATTR_VALUE not in state.attributes + async def test_node_status_sensor( hass: HomeAssistant, client, lock_id_lock_as_id150, integration From 03300c24da49a5cbe0fd32fb2e9d1fce58c5d64f Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Mon, 22 May 2023 12:04:04 -0400 Subject: [PATCH 446/822] Bugfix and small refactor for zwave_js.device_action (#93261) Improve test coverage for zwave_js.device_action --- homeassistant/components/zwave_js/device_action.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/zwave_js/device_action.py b/homeassistant/components/zwave_js/device_action.py index 3a585b44f586..bfb7956432be 100644 --- a/homeassistant/components/zwave_js/device_action.py +++ b/homeassistant/components/zwave_js/device_action.py @@ -186,8 +186,9 @@ async def async_get_actions( # underlying value is not being monitored by HA so we shouldn't allow # actions against it. if ( - state := hass.states.get(entry.entity_id) - ) and state.state == STATE_UNAVAILABLE: + not (state := hass.states.get(entry.entity_id)) + or state.state == STATE_UNAVAILABLE + ): continue entity_action = {**base_action, CONF_ENTITY_ID: entry.entity_id} actions.append({**entity_action, CONF_TYPE: SERVICE_REFRESH_VALUE}) @@ -209,9 +210,7 @@ async def async_get_actions( # If the value has the meterType CC specific value, we can add a reset_meter # action for it if CC_SPECIFIC_METER_TYPE in value.metadata.cc_specific: - endpoint_idx = value.endpoint - if endpoint_idx is None: - endpoint_idx = 0 + endpoint_idx = value.endpoint or 0 meter_endpoints[endpoint_idx].setdefault( CONF_ENTITY_ID, entry.entity_id ) From 18eeeaaf68cbc2a1080476e9c2eca20e52fe893b Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Mon, 22 May 2023 12:08:13 -0400 Subject: [PATCH 447/822] Refactor zwave_js.fan and add tests (#93256) * Refactor zwave_js.fan and add tests * fix const --- .../components/zwave_js/discovery.py | 8 +++ homeassistant/components/zwave_js/fan.py | 37 +++------- tests/components/zwave_js/test_fan.py | 68 +++++++++++++++++++ 3 files changed, 86 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index bea2836fead7..79a37c8d9fab 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -240,6 +240,12 @@ SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA = ZWaveValueDiscoverySchema( type={ValueType.NUMBER}, ) +SWITCH_MULTILEVEL_TARGET_VALUE_SCHEMA = ZWaveValueDiscoverySchema( + command_class={CommandClass.SWITCH_MULTILEVEL}, + property={TARGET_VALUE_PROPERTY}, + type={ValueType.NUMBER}, +) + SWITCH_BINARY_CURRENT_VALUE_SCHEMA = ZWaveValueDiscoverySchema( command_class={CommandClass.SWITCH_BINARY}, property={CURRENT_VALUE_PROPERTY} ) @@ -261,6 +267,7 @@ DISCOVERY_SCHEMAS = [ product_id={0x3131}, product_type={0x4944}, primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, + required_values=[SWITCH_MULTILEVEL_TARGET_VALUE_SCHEMA], ), # GE/Jasco - In-Wall Smart Fan Control - 12730 / ZW4002 ZWaveDiscoverySchema( @@ -842,6 +849,7 @@ DISCOVERY_SCHEMAS = [ device_class_generic={"Multilevel Switch"}, device_class_specific={"Fan Switch"}, primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, + required_values=[SWITCH_MULTILEVEL_TARGET_VALUE_SCHEMA], ), # number platform # valve control for thermostats diff --git a/homeassistant/components/zwave_js/fan.py b/homeassistant/components/zwave_js/fan.py index 224921b1b785..bb634328a069 100644 --- a/homeassistant/components/zwave_js/fan.py +++ b/homeassistant/components/zwave_js/fan.py @@ -25,7 +25,6 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) @@ -85,7 +84,9 @@ class ZwaveFan(ZWaveBaseEntity, FanEntity): ) -> None: """Initialize the fan.""" super().__init__(config_entry, driver, info) - self._target_value = self.get_zwave_value(TARGET_VALUE_PROPERTY) + target_value = self.get_zwave_value(TARGET_VALUE_PROPERTY) + assert target_value + self._target_value = target_value async def async_set_percentage(self, percentage: int) -> None: """Set the speed percentage of the fan.""" @@ -96,9 +97,7 @@ class ZwaveFan(ZWaveBaseEntity, FanEntity): percentage_to_ranged_value(DEFAULT_SPEED_RANGE, percentage) ) - if (target_value := self._target_value) is None: - raise HomeAssistantError("Missing target value on device.") - await self.info.node.async_set_value(target_value, zwave_speed) + await self.info.node.async_set_value(self._target_value, zwave_speed) async def async_turn_on( self, @@ -112,16 +111,12 @@ class ZwaveFan(ZWaveBaseEntity, FanEntity): elif preset_mode is not None: await self.async_set_preset_mode(preset_mode) else: - if (target_value := self._target_value) is None: - raise HomeAssistantError("Missing target value on device.") # Value 255 tells device to return to previous value - await self.info.node.async_set_value(target_value, 255) + await self.info.node.async_set_value(self._target_value, 255) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" - if (target_value := self._target_value) is None: - raise HomeAssistantError("Missing target value on device.") - await self.info.node.async_set_value(target_value, 0) + await self.info.node.async_set_value(self._target_value, 0) @property def is_on(self) -> bool | None: @@ -146,11 +141,6 @@ class ZwaveFan(ZWaveBaseEntity, FanEntity): """Return the step size for percentage.""" return 1 - @property - def speed_count(self) -> int: - """Return the number of speeds the fan supports.""" - return int_states_in_range(DEFAULT_SPEED_RANGE) - class ValueMappingZwaveFan(ZwaveFan): """A Zwave fan with a value mapping data (e.g., 1-24 is low).""" @@ -166,18 +156,14 @@ class ValueMappingZwaveFan(ZwaveFan): async def async_set_percentage(self, percentage: int) -> None: """Set the speed percentage of the fan.""" - if (target_value := self._target_value) is None: - raise HomeAssistantError("Missing target value on device.") zwave_speed = self.percentage_to_zwave_speed(percentage) - await self.info.node.async_set_value(target_value, zwave_speed) + await self.info.node.async_set_value(self._target_value, zwave_speed) async def async_set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" - if (target_value := self._target_value) is None: - raise HomeAssistantError("Missing target value on device.") for zwave_value, mapped_preset_mode in self.fan_value_mapping.presets.items(): if preset_mode == mapped_preset_mode: - await self.info.node.async_set_value(target_value, zwave_value) + await self.info.node.async_set_value(self._target_value, zwave_value) return raise NotValidPresetModeError( @@ -277,12 +263,9 @@ class ValueMappingZwaveFan(ZwaveFan): assert step_percentage if percentage <= step_percentage: - return max_speed + break - # This shouldn't actually happen; the last entry in - # `self.fan_value_mapping.speeds` should map to 100%. - (_, last_max_speed) = self.fan_value_mapping.speeds[-1] - return last_max_speed + return max_speed def zwave_speed_to_percentage(self, zwave_speed: int) -> int | None: """Convert a Zwave speed to a percentage. diff --git a/tests/components/zwave_js/test_fan.py b/tests/components/zwave_js/test_fan.py index d9de2379ce42..471246c59aab 100644 --- a/tests/components/zwave_js/test_fan.py +++ b/tests/components/zwave_js/test_fan.py @@ -13,6 +13,7 @@ from homeassistant.components.fan import ( ATTR_PRESET_MODE, ATTR_PRESET_MODES, DOMAIN as FAN_DOMAIN, + SERVICE_SET_PERCENTAGE, SERVICE_SET_PRESET_MODE, FanEntityFeature, NotValidPresetModeError, @@ -167,6 +168,50 @@ async def test_generic_fan( assert state.state == "off" assert state.attributes[ATTR_PERCENTAGE] == 0 + client.async_send_command.reset_mock() + + # Test setting percentage to 0 + await hass.services.async_call( + "fan", + SERVICE_SET_PERCENTAGE, + {"entity_id": entity_id, "percentage": 0}, + 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"] == 17 + assert args["valueId"] == { + "commandClass": 38, + "endpoint": 0, + "property": "targetValue", + } + assert args["value"] == 0 + + # Test value is None + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 17, + "args": { + "commandClassName": "Multilevel Switch", + "commandClass": 38, + "endpoint": 0, + "property": "currentValue", + "newValue": None, + "prevValue": 0, + "propertyName": "currentValue", + }, + }, + ) + node.receive_event(event) + + state = hass.states.get(entity_id) + assert state.state == STATE_UNKNOWN + async def test_configurable_speeds_fan( hass: HomeAssistant, client, hs_fc200, integration @@ -361,6 +406,29 @@ async def test_ge_12730_fan(hass: HomeAssistant, client, ge_12730, integration) assert state.attributes[ATTR_PERCENTAGE_STEP] == pytest.approx(33.3333, rel=1e-3) assert state.attributes[ATTR_PRESET_MODES] == [] + # Test value is None + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node_id, + "args": { + "commandClassName": "Multilevel Switch", + "commandClass": 38, + "endpoint": 0, + "property": "currentValue", + "newValue": None, + "prevValue": 0, + "propertyName": "currentValue", + }, + }, + ) + node.receive_event(event) + + state = hass.states.get(entity_id) + assert state.state == STATE_UNKNOWN + async def test_inovelli_lzw36( hass: HomeAssistant, client, inovelli_lzw36, integration From 3bf9eaffdfaa48f053bc479a6af62c43263a065d Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Mon, 22 May 2023 12:08:29 -0400 Subject: [PATCH 448/822] Bugfix and add test coverage for zwave_js.light (#93257) Add test coverage for zwave_js.light --- homeassistant/components/zwave_js/light.py | 7 +- tests/components/zwave_js/test_light.py | 75 ++++++++++++++++++++++ 2 files changed, 78 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zwave_js/light.py b/homeassistant/components/zwave_js/light.py index 29654503f001..549685873aa4 100644 --- a/homeassistant/components/zwave_js/light.py +++ b/homeassistant/components/zwave_js/light.py @@ -444,8 +444,8 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): class ZwaveBlackIsOffLight(ZwaveLight): """Representation of a Z-Wave light where setting the color to black turns it off. - Currently only supports lights with RGB, no color temperature, - and no white channels. + Currently only supports lights with RGB, no color temperature, and no white + channels. """ def __init__( @@ -471,13 +471,12 @@ class ZwaveBlackIsOffLight(ZwaveLight): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" - await super().async_turn_on(**kwargs) - if ( kwargs.get(ATTR_RGBW_COLOR) is not None or kwargs.get(ATTR_COLOR_TEMP) is not None or kwargs.get(ATTR_HS_COLOR) is not None ): + await super().async_turn_on(**kwargs) return transition = kwargs.get(ATTR_TRANSITION) diff --git a/tests/components/zwave_js/test_light.py b/tests/components/zwave_js/test_light.py index 7546976d265a..44cb9d70f76f 100644 --- a/tests/components/zwave_js/test_light.py +++ b/tests/components/zwave_js/test_light.py @@ -23,6 +23,7 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_OFF, STATE_ON, + STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant @@ -397,6 +398,33 @@ async def test_light( } assert args["value"] == 0 + client.async_send_command.reset_mock() + + # Test brightness update to None from value updated event + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 39, + "args": { + "commandClassName": "Multilevel Switch", + "commandClass": 38, + "endpoint": 0, + "property": "currentValue", + "newValue": None, + "prevValue": 99, + "propertyName": "currentValue", + }, + }, + ) + node.receive_event(event) + + state = hass.states.get(BULB_6_MULTI_COLOR_LIGHT_ENTITY) + assert state.state == STATE_UNKNOWN + assert ATTR_COLOR_MODE not in state.attributes + assert ATTR_BRIGHTNESS not in state.attributes + async def test_v4_dimmer_light( hass: HomeAssistant, client, eaton_rf9640_dimmer, integration @@ -599,3 +627,50 @@ async def test_black_is_off( 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": 0, + "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(HSM200_V1_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: HSM200_V1_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": 0, + "property": "targetColor", + } + assert args["value"] == {"red": 255, "green": 76, "blue": 255} From b10e73e2d4db9696e94daff1e831c9e6fe33edbe Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Mon, 22 May 2023 18:09:59 +0200 Subject: [PATCH 449/822] Configurable KNX Telegram history size (#93248) * Configurable KNX Telegram history size * Add maximum value to description --- homeassistant/components/knx/__init__.py | 9 +++++- homeassistant/components/knx/config_flow.py | 35 ++++++++++++++++++--- homeassistant/components/knx/const.py | 33 +++++++++++-------- homeassistant/components/knx/strings.json | 6 ++-- homeassistant/components/knx/telegrams.py | 10 ++++-- tests/components/knx/test_config_flow.py | 15 +++++++-- 6 files changed, 83 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index e177a861af34..8a8e87b893fb 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -60,6 +60,7 @@ from .const import ( CONF_KNX_SECURE_USER_ID, CONF_KNX_SECURE_USER_PASSWORD, CONF_KNX_STATE_UPDATER, + CONF_KNX_TELEGRAM_LOG_SIZE, CONF_KNX_TUNNELING, CONF_KNX_TUNNELING_TCP, CONF_KNX_TUNNELING_TCP_SECURE, @@ -68,6 +69,7 @@ from .const import ( DOMAIN, KNX_ADDRESS, SUPPORTED_PLATFORMS, + TELEGRAM_LOG_DEFAULT, ) from .device import KNXInterfaceDevice from .expose import KNXExposeSensor, KNXExposeTime, create_knx_exposure @@ -384,7 +386,12 @@ class KNXModule: self.xknx.connection_manager.register_connection_state_changed_cb( self.connection_state_changed_cb ) - self.telegrams = Telegrams(hass, self.xknx, self.project) + self.telegrams = Telegrams( + hass=hass, + xknx=self.xknx, + project=self.project, + log_size=entry.data.get(CONF_KNX_TELEGRAM_LOG_SIZE, TELEGRAM_LOG_DEFAULT), + ) self.interface_device = KNXInterfaceDevice( hass=hass, entry=entry, xknx=self.xknx ) diff --git a/homeassistant/components/knx/config_flow.py b/homeassistant/components/knx/config_flow.py index 81610d62dcf7..0a405146d9cb 100644 --- a/homeassistant/components/knx/config_flow.py +++ b/homeassistant/components/knx/config_flow.py @@ -49,12 +49,15 @@ from .const import ( CONF_KNX_SECURE_USER_ID, CONF_KNX_SECURE_USER_PASSWORD, CONF_KNX_STATE_UPDATER, + CONF_KNX_TELEGRAM_LOG_SIZE, CONF_KNX_TUNNEL_ENDPOINT_IA, CONF_KNX_TUNNELING, CONF_KNX_TUNNELING_TCP, CONF_KNX_TUNNELING_TCP_SECURE, DEFAULT_ROUTING_IA, DOMAIN, + TELEGRAM_LOG_DEFAULT, + TELEGRAM_LOG_MAX, KNXConfigEntryData, ) from .schema import ia_validator, ip_v4_validator @@ -70,6 +73,7 @@ DEFAULT_ENTRY_DATA = KNXConfigEntryData( rate_limit=CONF_KNX_DEFAULT_RATE_LIMIT, route_back=False, state_updater=CONF_KNX_DEFAULT_STATE_UPDATER, + telegram_log_size=TELEGRAM_LOG_DEFAULT, ) CONF_KEYRING_FILE: Final = "knxkeys_file" @@ -203,7 +207,11 @@ class KNXCommonFlow(ABC, FlowHandler): ) async def async_step_tunnel(self, user_input: dict | None = None) -> FlowResult: - """Select a tunnel from a list. Will be skipped if the gateway scan was unsuccessful or if only one gateway was found.""" + """Select a tunnel from a list. + + Will be skipped if the gateway scan was unsuccessful + or if only one gateway was found. + """ if user_input is not None: if user_input[CONF_KNX_GATEWAY] == OPTION_MANUAL_TUNNEL: if self._found_tunnels: @@ -804,6 +812,7 @@ class KNXOptionsFlow(KNXCommonFlow, OptionsFlow): self.new_entry_data = KNXConfigEntryData( state_updater=user_input[CONF_KNX_STATE_UPDATER], rate_limit=user_input[CONF_KNX_RATE_LIMIT], + telegram_log_size=user_input[CONF_KNX_TELEGRAM_LOG_SIZE], ) return self.finish_flow() @@ -811,15 +820,13 @@ class KNXOptionsFlow(KNXCommonFlow, OptionsFlow): vol.Required( CONF_KNX_STATE_UPDATER, default=self.initial_data.get( - CONF_KNX_STATE_UPDATER, - CONF_KNX_DEFAULT_STATE_UPDATER, + CONF_KNX_STATE_UPDATER, CONF_KNX_DEFAULT_STATE_UPDATER ), ): selector.BooleanSelector(), vol.Required( CONF_KNX_RATE_LIMIT, default=self.initial_data.get( - CONF_KNX_RATE_LIMIT, - CONF_KNX_DEFAULT_RATE_LIMIT, + CONF_KNX_RATE_LIMIT, CONF_KNX_DEFAULT_RATE_LIMIT ), ): vol.All( selector.NumberSelector( @@ -831,9 +838,27 @@ class KNXOptionsFlow(KNXCommonFlow, OptionsFlow): ), vol.Coerce(int), ), + vol.Required( + CONF_KNX_TELEGRAM_LOG_SIZE, + default=self.initial_data.get( + CONF_KNX_TELEGRAM_LOG_SIZE, TELEGRAM_LOG_DEFAULT + ), + ): vol.All( + selector.NumberSelector( + selector.NumberSelectorConfig( + min=0, + max=TELEGRAM_LOG_MAX, + mode=selector.NumberSelectorMode.BOX, + ), + ), + vol.Coerce(int), + ), } return self.async_show_form( step_id="communication_settings", data_schema=vol.Schema(data_schema), last_step=True, + description_placeholders={ + "telegram_log_size_max": f"{TELEGRAM_LOG_MAX}", + }, ) diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py index 858f1cefea07..5546a2d6fd9c 100644 --- a/homeassistant/components/knx/const.py +++ b/homeassistant/components/knx/const.py @@ -52,6 +52,10 @@ CONF_KNX_DEFAULT_RATE_LIMIT: Final = 0 DEFAULT_ROUTING_IA: Final = "0.0.240" +CONF_KNX_TELEGRAM_LOG_SIZE: Final = "telegram_log_size" +TELEGRAM_LOG_DEFAULT: Final = 50 +TELEGRAM_LOG_MAX: Final = 5000 # ~2 MB or ~5 hours of reasonable bus load + ## # Secure constants ## @@ -88,23 +92,26 @@ class KNXConfigEntryData(TypedDict, total=False): connection_type: str individual_address: str - local_ip: str | None + local_ip: str | None # not required multicast_group: str multicast_port: int - route_back: bool + route_back: bool # not required + host: str # only required for tunnelling + port: int # only required for tunnelling + tunnel_endpoint_ia: str | None + # KNX secure + user_id: int | None # not required + user_password: str | None # not required + device_authentication: str | None # not required + knxkeys_filename: str # not required + knxkeys_password: str # not required + backbone_key: str | None # not required + sync_latency_tolerance: int | None # not required + # OptionsFlow only state_updater: bool rate_limit: int - host: str - port: int - tunnel_endpoint_ia: str | None - - user_id: int | None - user_password: str | None - device_authentication: str | None - knxkeys_filename: str - knxkeys_password: str - backbone_key: str | None - sync_latency_tolerance: int | None + # Integration only (not forwarded to xknx) + telegram_log_size: int # not required class KNXBusMonitorMessage(TypedDict): diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json index d4a1eae11c2f..cdd61379567d 100644 --- a/homeassistant/components/knx/strings.json +++ b/homeassistant/components/knx/strings.json @@ -133,11 +133,13 @@ "title": "Communication settings", "data": { "state_updater": "State updater", - "rate_limit": "Rate limit" + "rate_limit": "Rate limit", + "telegram_log_size": "Telegram history limit" }, "data_description": { "state_updater": "Set default for reading states from the KNX Bus. When disabled, Home Assistant will not actively retrieve entity states from the KNX Bus. Can be overridden by `sync_state` entity options.", - "rate_limit": "Maximum outgoing telegrams per second.\n`0` to disable limit. Recommended: 0 or 20 to 40" + "rate_limit": "Maximum outgoing telegrams per second.\n`0` to disable limit. Recommended: 0 or 20 to 40", + "telegram_log_size": "Telegrams to keep in memory for KNX panel group monitor. Maximum: {telegram_log_size_max}" } }, "connection_type": { diff --git a/homeassistant/components/knx/telegrams.py b/homeassistant/components/knx/telegrams.py index 4c5ac44f6b19..5b429b0bdc18 100644 --- a/homeassistant/components/knx/telegrams.py +++ b/homeassistant/components/knx/telegrams.py @@ -35,7 +35,13 @@ class TelegramDict(TypedDict): class Telegrams: """Class to handle KNX telegrams.""" - def __init__(self, hass: HomeAssistant, xknx: XKNX, project: KNXProject) -> None: + def __init__( + self, + hass: HomeAssistant, + xknx: XKNX, + project: KNXProject, + log_size: int, + ) -> None: """Initialize Telegrams class.""" self.hass = hass self.project = project @@ -46,7 +52,7 @@ class Telegrams: match_for_outgoing=True, ) ) - self.recent_telegrams: deque[TelegramDict] = deque(maxlen=50) + self.recent_telegrams: deque[TelegramDict] = deque(maxlen=log_size) async def _xknx_telegram_cb(self, telegram: Telegram) -> None: """Handle incoming and outgoing telegrams from xknx.""" diff --git a/tests/components/knx/test_config_flow.py b/tests/components/knx/test_config_flow.py index 054d78447144..ca804176ee9b 100644 --- a/tests/components/knx/test_config_flow.py +++ b/tests/components/knx/test_config_flow.py @@ -37,6 +37,7 @@ from homeassistant.components.knx.const import ( CONF_KNX_SECURE_USER_ID, CONF_KNX_SECURE_USER_PASSWORD, CONF_KNX_STATE_UPDATER, + CONF_KNX_TELEGRAM_LOG_SIZE, CONF_KNX_TUNNEL_ENDPOINT_IA, CONF_KNX_TUNNELING, CONF_KNX_TUNNELING_TCP, @@ -820,7 +821,6 @@ async def test_tunneling_setup_for_multiple_found_gateways( CONF_PORT: 3675, CONF_KNX_INDIVIDUAL_ADDRESS: "0.0.240", CONF_KNX_ROUTE_BACK: False, - CONF_KNX_LOCAL_IP: None, CONF_KNX_TUNNEL_ENDPOINT_IA: None, CONF_KNX_SECURE_DEVICE_AUTHENTICATION: None, CONF_KNX_SECURE_USER_ID: None, @@ -900,9 +900,17 @@ async def test_form_with_automatic_connection_handling( assert result2["type"] == FlowResultType.CREATE_ENTRY assert result2["title"] == CONF_KNX_AUTOMATIC.capitalize() assert result2["data"] == { - **DEFAULT_ENTRY_DATA, + # don't use **DEFAULT_ENTRY_DATA here to check for correct usage of defaults CONF_KNX_CONNECTION_TYPE: CONF_KNX_AUTOMATIC, + CONF_KNX_INDIVIDUAL_ADDRESS: "0.0.240", + CONF_KNX_LOCAL_IP: None, + CONF_KNX_MCAST_PORT: DEFAULT_MCAST_PORT, + CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, + CONF_KNX_RATE_LIMIT: 0, + CONF_KNX_ROUTE_BACK: False, CONF_KNX_TUNNEL_ENDPOINT_IA: None, + CONF_KNX_STATE_UPDATER: True, + CONF_KNX_TELEGRAM_LOG_SIZE: 50, } knx_setup.assert_called_once() @@ -1202,6 +1210,7 @@ async def test_options_flow_connection_type( CONF_KNX_SECURE_DEVICE_AUTHENTICATION: None, CONF_KNX_SECURE_USER_ID: None, CONF_KNX_SECURE_USER_PASSWORD: None, + CONF_KNX_TELEGRAM_LOG_SIZE: 50, } @@ -1331,6 +1340,7 @@ async def test_options_communication_settings( user_input={ CONF_KNX_STATE_UPDATER: False, CONF_KNX_RATE_LIMIT: 40, + CONF_KNX_TELEGRAM_LOG_SIZE: 3000, }, ) await hass.async_block_till_done() @@ -1341,6 +1351,7 @@ async def test_options_communication_settings( CONF_KNX_CONNECTION_TYPE: CONF_KNX_AUTOMATIC, CONF_KNX_STATE_UPDATER: False, CONF_KNX_RATE_LIMIT: 40, + CONF_KNX_TELEGRAM_LOG_SIZE: 3000, } knx_setup.assert_called_once() From 91da7516d0500c4b1f98edcc6e45f1f1c7c04f1b Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Mon, 22 May 2023 18:16:57 +0200 Subject: [PATCH 450/822] Use snapshots in devolo_home_control tests (#93202) --- .../devolo_home_control/__init__.py | 5 +- .../snapshots/test_binary_sensor.ambr | 129 ++++++++++++ .../snapshots/test_climate.ambr | 59 ++++++ .../snapshots/test_cover.ambr | 46 +++++ .../snapshots/test_diagnostics.ambr | 53 +++++ .../snapshots/test_light.ambr | 105 ++++++++++ .../snapshots/test_sensor.ambr | 189 ++++++++++++++++++ .../snapshots/test_siren.ambr | 151 ++++++++++++++ .../snapshots/test_switch.ambr | 43 ++++ .../devolo_home_control/test_binary_sensor.py | 33 ++- .../devolo_home_control/test_climate.py | 20 +- .../devolo_home_control/test_cover.py | 20 +- .../devolo_home_control/test_diagnostics.py | 44 +--- .../devolo_home_control/test_light.py | 38 ++-- .../devolo_home_control/test_sensor.py | 64 ++---- .../devolo_home_control/test_siren.py | 33 +-- .../devolo_home_control/test_switch.py | 12 +- 17 files changed, 874 insertions(+), 170 deletions(-) create mode 100644 tests/components/devolo_home_control/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/devolo_home_control/snapshots/test_climate.ambr create mode 100644 tests/components/devolo_home_control/snapshots/test_cover.ambr create mode 100644 tests/components/devolo_home_control/snapshots/test_diagnostics.ambr create mode 100644 tests/components/devolo_home_control/snapshots/test_light.ambr create mode 100644 tests/components/devolo_home_control/snapshots/test_sensor.ambr create mode 100644 tests/components/devolo_home_control/snapshots/test_siren.ambr create mode 100644 tests/components/devolo_home_control/snapshots/test_switch.ambr diff --git a/tests/components/devolo_home_control/__init__.py b/tests/components/devolo_home_control/__init__.py index 5ffc0781c842..a7217b0d530d 100644 --- a/tests/components/devolo_home_control/__init__.py +++ b/tests/components/devolo_home_control/__init__.py @@ -1,5 +1,4 @@ """Tests for the devolo_home_control integration.""" - from homeassistant.components.devolo_home_control.const import DOMAIN from homeassistant.core import HomeAssistant @@ -13,7 +12,9 @@ def configure_integration(hass: HomeAssistant) -> MockConfigEntry: "password": "test-password", "mydevolo_url": "https://test_mydevolo_url.test", } - entry = MockConfigEntry(domain=DOMAIN, data=config, unique_id="123456") + entry = MockConfigEntry( + domain=DOMAIN, data=config, entry_id="123456", unique_id="123456" + ) entry.add_to_hass(hass) return entry diff --git a/tests/components/devolo_home_control/snapshots/test_binary_sensor.ambr b/tests/components/devolo_home_control/snapshots/test_binary_sensor.ambr new file mode 100644 index 000000000000..0c86cc94321b --- /dev/null +++ b/tests/components/devolo_home_control/snapshots/test_binary_sensor.ambr @@ -0,0 +1,129 @@ +# serializer version: 1 +# name: test_binary_sensor + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Test Door', + }), + 'context': , + 'entity_id': 'binary_sensor.test_door', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door', + 'platform': 'devolo_home_control', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Test', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor.2 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'safety', + 'friendly_name': 'Test Overload', + }), + 'context': , + 'entity_id': 'binary_sensor.test_overload', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor.3 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_overload', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overload', + 'platform': 'devolo_home_control', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Overload', + 'unit_of_measurement': None, + }) +# --- +# name: test_remote_control + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Button 1', + }), + 'context': , + 'entity_id': 'binary_sensor.test_button_1', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_remote_control.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_button_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Button 1', + 'platform': 'devolo_home_control', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Test_1', + 'unit_of_measurement': None, + }) +# --- diff --git a/tests/components/devolo_home_control/snapshots/test_climate.ambr b/tests/components/devolo_home_control/snapshots/test_climate.ambr new file mode 100644 index 000000000000..e0066a106561 --- /dev/null +++ b/tests/components/devolo_home_control/snapshots/test_climate.ambr @@ -0,0 +1,59 @@ +# serializer version: 1 +# name: test_climate + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20, + 'friendly_name': 'Test', + 'hvac_modes': list([ + , + ]), + 'max_temp': 24, + 'min_temp': 4, + 'supported_features': , + 'target_temp_step': 0.5, + 'temperature': 20, + }), + 'context': , + 'entity_id': 'climate.test', + 'last_changed': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_climate.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + ]), + 'max_temp': 24, + 'min_temp': 4, + 'target_temp_step': 0.5, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'devolo_home_control', + 'supported_features': , + 'translation_key': None, + 'unique_id': 'Test', + 'unit_of_measurement': None, + }) +# --- diff --git a/tests/components/devolo_home_control/snapshots/test_cover.ambr b/tests/components/devolo_home_control/snapshots/test_cover.ambr new file mode 100644 index 000000000000..b2872d0c912a --- /dev/null +++ b/tests/components/devolo_home_control/snapshots/test_cover.ambr @@ -0,0 +1,46 @@ +# serializer version: 1 +# name: test_cover + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 20, + 'device_class': 'blind', + 'friendly_name': 'Test', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test', + 'last_changed': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_cover.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.test', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'devolo_home_control', + 'supported_features': , + 'translation_key': None, + 'unique_id': 'devolo.Blinds', + 'unit_of_measurement': None, + }) +# --- diff --git a/tests/components/devolo_home_control/snapshots/test_diagnostics.ambr b/tests/components/devolo_home_control/snapshots/test_diagnostics.ambr new file mode 100644 index 000000000000..d2ff64ad5965 --- /dev/null +++ b/tests/components/devolo_home_control/snapshots/test_diagnostics.ambr @@ -0,0 +1,53 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'device_info': list([ + dict({ + 'devices': list([ + dict({ + 'device_id': 'Test', + 'device_model_uid': 'Test', + 'device_type': 'Test', + 'name': 'Test Device', + }), + dict({ + 'device_id': 'Overload', + 'device_model_uid': 'Test', + 'device_type': 'Test', + 'name': 'Test Device', + }), + ]), + 'gateway': dict({ + 'firmware_version': '8.94.0', + 'local_connection': True, + }), + }), + dict({ + 'devices': list([ + ]), + 'gateway': dict({ + 'firmware_version': '8.94.0', + 'local_connection': True, + }), + }), + ]), + 'entry': dict({ + 'data': dict({ + 'mydevolo_url': 'https://test_mydevolo_url.test', + 'password': '**REDACTED**', + 'username': '**REDACTED**', + }), + 'disabled_by': None, + 'domain': 'devolo_home_control', + 'entry_id': '123456', + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Mock Title', + 'unique_id': '123456', + 'version': 1, + }), + }) +# --- diff --git a/tests/components/devolo_home_control/snapshots/test_light.ambr b/tests/components/devolo_home_control/snapshots/test_light.ambr new file mode 100644 index 000000000000..81c1e9b4293e --- /dev/null +++ b/tests/components/devolo_home_control/snapshots/test_light.ambr @@ -0,0 +1,105 @@ +# serializer version: 1 +# name: test_light_with_binary_sensor + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 51, + 'color_mode': , + 'friendly_name': 'Test', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.test', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_light_with_binary_sensor.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.test', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'devolo_home_control', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'devolo.Dimmer:Test', + 'unit_of_measurement': None, + }) +# --- +# name: test_light_without_binary_sensor + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 51, + 'color_mode': , + 'friendly_name': 'Test', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.test', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_light_without_binary_sensor.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.test', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'devolo_home_control', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'devolo.Dimmer:Test', + 'unit_of_measurement': None, + }) +# --- diff --git a/tests/components/devolo_home_control/snapshots/test_sensor.ambr b/tests/components/devolo_home_control/snapshots/test_sensor.ambr new file mode 100644 index 000000000000..cb97ce77af07 --- /dev/null +++ b/tests/components/devolo_home_control/snapshots/test_sensor.ambr @@ -0,0 +1,189 @@ +# serializer version: 1 +# name: test_battery_sensor + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Test Battery level', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_battery_level', + 'last_changed': , + 'last_updated': , + 'state': '25', + }) +# --- +# name: test_battery_sensor.1 + 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': , + 'entity_id': 'sensor.test_battery_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery level', + 'platform': 'devolo_home_control', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'devolo.BatterySensor:Test', + 'unit_of_measurement': '%', + }) +# --- +# name: test_consumption_sensor + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Test Current consumption', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.test_current_consumption', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_consumption_sensor.1 + 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.test_current_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current consumption', + 'platform': 'devolo_home_control', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'devolo.Meter:Test_current', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_consumption_sensor.2 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test Total consumption', + 'state_class': , + 'unit_of_measurement': 'kWh', + }), + 'context': , + 'entity_id': 'sensor.test_total_consumption', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_consumption_sensor.3 + 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.test_total_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total consumption', + 'platform': 'devolo_home_control', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'devolo.Meter:Test_total', + 'unit_of_measurement': 'kWh', + }) +# --- +# name: test_temperature_sensor + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Test Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_temperature', + 'last_changed': , + 'last_updated': , + 'state': '20', + }) +# --- +# name: test_temperature_sensor.1 + 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.test_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'devolo_home_control', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'devolo.MultiLevelSensor:Test', + 'unit_of_measurement': , + }) +# --- diff --git a/tests/components/devolo_home_control/snapshots/test_siren.ambr b/tests/components/devolo_home_control/snapshots/test_siren.ambr new file mode 100644 index 000000000000..df1d514a11d1 --- /dev/null +++ b/tests/components/devolo_home_control/snapshots/test_siren.ambr @@ -0,0 +1,151 @@ +# serializer version: 1 +# name: test_siren + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'available_tones': list([ + 0, + ]), + 'friendly_name': 'Test', + 'supported_features': , + }), + 'context': , + 'entity_id': 'siren.test', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_siren.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'available_tones': list([ + 0, + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'siren', + 'entity_category': None, + 'entity_id': 'siren.test', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'devolo_home_control', + 'supported_features': , + 'translation_key': None, + 'unique_id': 'devolo.SirenMultiLevelSwitch:Test', + 'unit_of_measurement': None, + }) +# --- +# name: test_siren_change_default_tone + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'available_tones': list([ + 0, + ]), + 'friendly_name': 'Test', + 'supported_features': , + }), + 'context': , + 'entity_id': 'siren.test', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_siren_change_default_tone.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'available_tones': list([ + 0, + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'siren', + 'entity_category': None, + 'entity_id': 'siren.test', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'devolo_home_control', + 'supported_features': , + 'translation_key': None, + 'unique_id': 'devolo.SirenMultiLevelSwitch:Test', + 'unit_of_measurement': None, + }) +# --- +# name: test_siren_switching + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'available_tones': list([ + 0, + ]), + 'friendly_name': 'Test', + 'supported_features': , + }), + 'context': , + 'entity_id': 'siren.test', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_siren_switching.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'available_tones': list([ + 0, + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'siren', + 'entity_category': None, + 'entity_id': 'siren.test', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'devolo_home_control', + 'supported_features': , + 'translation_key': None, + 'unique_id': 'devolo.SirenMultiLevelSwitch:Test', + 'unit_of_measurement': None, + }) +# --- diff --git a/tests/components/devolo_home_control/snapshots/test_switch.ambr b/tests/components/devolo_home_control/snapshots/test_switch.ambr new file mode 100644 index 000000000000..4aa95944be05 --- /dev/null +++ b/tests/components/devolo_home_control/snapshots/test_switch.ambr @@ -0,0 +1,43 @@ +# serializer version: 1 +# name: test_switch + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test', + }), + 'context': , + 'entity_id': 'switch.test', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.test', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'devolo_home_control', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'devolo.BinarySwitch:Test', + 'unit_of_measurement': None, + }) +# --- diff --git a/tests/components/devolo_home_control/test_binary_sensor.py b/tests/components/devolo_home_control/test_binary_sensor.py index 1fa2248c7171..ffb1794f0061 100644 --- a/tests/components/devolo_home_control/test_binary_sensor.py +++ b/tests/components/devolo_home_control/test_binary_sensor.py @@ -2,15 +2,10 @@ from unittest.mock import patch import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.binary_sensor import DOMAIN -from homeassistant.const import ( - ATTR_FRIENDLY_NAME, - STATE_OFF, - STATE_ON, - STATE_UNAVAILABLE, - EntityCategory, -) +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -25,7 +20,7 @@ from .mocks import ( @pytest.mark.usefixtures("mock_zeroconf") async def test_binary_sensor( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion ) -> None: """Test setup and state change of a binary sensor device.""" entry = configure_integration(hass) @@ -39,17 +34,12 @@ async def test_binary_sensor( await hass.async_block_till_done() state = hass.states.get(f"{DOMAIN}.test_door") - assert state is not None - assert state.state == STATE_OFF - assert state.attributes[ATTR_FRIENDLY_NAME] == "Test Door" + assert state == snapshot + assert entity_registry.async_get(f"{DOMAIN}.test_door") == snapshot state = hass.states.get(f"{DOMAIN}.test_overload") - assert state is not None - assert state.attributes[ATTR_FRIENDLY_NAME] == "Test Overload" - assert ( - entity_registry.async_get(f"{DOMAIN}.test_overload").entity_category - == EntityCategory.DIAGNOSTIC - ) + assert state == snapshot + assert entity_registry.async_get(f"{DOMAIN}.test_overload") == snapshot # Emulate websocket message: sensor turned on test_gateway.publisher.dispatch("Test", ("Test", True)) @@ -64,7 +54,9 @@ async def test_binary_sensor( @pytest.mark.usefixtures("mock_zeroconf") -async def test_remote_control(hass: HomeAssistant) -> None: +async def test_remote_control( + hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion +) -> None: """Test setup and state change of a remote control device.""" entry = configure_integration(hass) test_gateway = HomeControlMockRemoteControl() @@ -77,9 +69,8 @@ async def test_remote_control(hass: HomeAssistant) -> None: await hass.async_block_till_done() state = hass.states.get(f"{DOMAIN}.test_button_1") - assert state is not None - assert state.state == STATE_OFF - assert state.attributes[ATTR_FRIENDLY_NAME] == "Test Button 1" + assert state == snapshot + assert entity_registry.async_get(f"{DOMAIN}.test_button_1") == snapshot # Emulate websocket message: button pressed test_gateway.publisher.dispatch("Test", ("Test", 1)) diff --git a/tests/components/devolo_home_control/test_climate.py b/tests/components/devolo_home_control/test_climate.py index fc93125a7bbb..11d5b01ec5ab 100644 --- a/tests/components/devolo_home_control/test_climate.py +++ b/tests/components/devolo_home_control/test_climate.py @@ -1,25 +1,25 @@ """Tests for the devolo Home Control climate.""" from unittest.mock import patch +from syrupy.assertion import SnapshotAssertion + from homeassistant.components.climate import ( ATTR_HVAC_MODE, DOMAIN, SERVICE_SET_TEMPERATURE, HVACMode, ) -from homeassistant.const import ( - ATTR_ENTITY_ID, - ATTR_FRIENDLY_NAME, - ATTR_TEMPERATURE, - STATE_UNAVAILABLE, -) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from . import configure_integration from .mocks import HomeControlMock, HomeControlMockClimate -async def test_climate(hass: HomeAssistant) -> None: +async def test_climate( + hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion +) -> None: """Test setup and state change of a climate device.""" entry = configure_integration(hass) test_gateway = HomeControlMockClimate() @@ -32,10 +32,8 @@ async def test_climate(hass: HomeAssistant) -> None: await hass.async_block_till_done() state = hass.states.get(f"{DOMAIN}.test") - assert state is not None - assert state.state == HVACMode.HEAT - assert state.attributes[ATTR_TEMPERATURE] == test_gateway.devices["Test"].value - assert state.attributes[ATTR_FRIENDLY_NAME] == "Test" + assert state == snapshot + assert entity_registry.async_get(f"{DOMAIN}.test") == snapshot # Emulate websocket message: temperature changed test_gateway.publisher.dispatch("Test", ("Test", 21.0)) diff --git a/tests/components/devolo_home_control/test_cover.py b/tests/components/devolo_home_control/test_cover.py index 190d339cc117..54f0cc34222a 100644 --- a/tests/components/devolo_home_control/test_cover.py +++ b/tests/components/devolo_home_control/test_cover.py @@ -1,24 +1,27 @@ """Tests for the devolo Home Control cover platform.""" from unittest.mock import patch +from syrupy.assertion import SnapshotAssertion + from homeassistant.components.cover import ATTR_CURRENT_POSITION, ATTR_POSITION, DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_FRIENDLY_NAME, SERVICE_CLOSE_COVER, SERVICE_OPEN_COVER, SERVICE_SET_COVER_POSITION, STATE_CLOSED, - STATE_OPEN, STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from . import configure_integration from .mocks import HomeControlMock, HomeControlMockCover -async def test_cover(hass: HomeAssistant) -> None: +async def test_cover( + hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion +) -> None: """Test setup and state change of a cover device.""" entry = configure_integration(hass) test_gateway = HomeControlMockCover() @@ -31,15 +34,8 @@ async def test_cover(hass: HomeAssistant) -> None: await hass.async_block_till_done() state = hass.states.get(f"{DOMAIN}.test") - assert state is not None - assert state.state == STATE_OPEN - assert state.attributes[ATTR_FRIENDLY_NAME] == "Test" - assert ( - state.attributes[ATTR_CURRENT_POSITION] - == test_gateway.devices["Test"] - .multi_level_switch_property["devolo.Blinds"] - .value - ) + assert state == snapshot + assert entity_registry.async_get(f"{DOMAIN}.test") == snapshot # Emulate websocket message: position changed test_gateway.publisher.dispatch("Test", ("devolo.Blinds", 0.0)) diff --git a/tests/components/devolo_home_control/test_diagnostics.py b/tests/components/devolo_home_control/test_diagnostics.py index 00c582ebf920..ad267c4c52e2 100644 --- a/tests/components/devolo_home_control/test_diagnostics.py +++ b/tests/components/devolo_home_control/test_diagnostics.py @@ -3,10 +3,8 @@ from __future__ import annotations from unittest.mock import patch -from aiohttp import ClientSession +from syrupy.assertion import SnapshotAssertion -from homeassistant.components.devolo_home_control.diagnostics import TO_REDACT -from homeassistant.components.diagnostics import REDACTED from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -14,9 +12,14 @@ from . import configure_integration from .mocks import HomeControlMock, HomeControlMockBinarySensor from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator -async def test_entry_diagnostics(hass: HomeAssistant, hass_client: ClientSession): +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, +) -> None: """Test setup and state change of a climate device.""" entry = configure_integration(hass) gateway_1 = HomeControlMockBinarySensor() @@ -30,36 +33,5 @@ async def test_entry_diagnostics(hass: HomeAssistant, hass_client: ClientSession assert entry.state == ConfigEntryState.LOADED - entry_dict = entry.as_dict() - for key in TO_REDACT: - entry_dict["data"][key] = REDACTED - result = await get_diagnostics_for_config_entry(hass, hass_client, entry) - - assert result == { - "entry": entry_dict, - "device_info": [ - { - "gateway": { - "local_connection": gateway_1.gateway.local_connection, - "firmware_version": gateway_1.gateway.firmware_version, - }, - "devices": [ - { - "device_id": device_id, - "device_model_uid": properties.device_model_uid, - "device_type": properties.device_type, - "name": properties.name, - } - for device_id, properties in gateway_1.devices.items() - ], - }, - { - "gateway": { - "local_connection": gateway_2.gateway.local_connection, - "firmware_version": gateway_2.gateway.firmware_version, - }, - "devices": [], - }, - ], - } + assert result == snapshot diff --git a/tests/components/devolo_home_control/test_light.py b/tests/components/devolo_home_control/test_light.py index 5a80a650b5e2..8cb31dde8cc7 100644 --- a/tests/components/devolo_home_control/test_light.py +++ b/tests/components/devolo_home_control/test_light.py @@ -1,16 +1,11 @@ """Tests for the devolo Home Control light platform.""" from unittest.mock import patch -from homeassistant.components.light import ( - ATTR_BRIGHTNESS, - ATTR_COLOR_MODE, - ATTR_SUPPORTED_COLOR_MODES, - DOMAIN, - ColorMode, -) +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.light import ATTR_BRIGHTNESS, DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_FRIENDLY_NAME, SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_OFF, @@ -18,12 +13,15 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from . import configure_integration from .mocks import BinarySwitchPropertyMock, HomeControlMock, HomeControlMockLight -async def test_light_without_binary_sensor(hass: HomeAssistant) -> None: +async def test_light_without_binary_sensor( + hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion +) -> None: """Test setup and state change of a light device that does not have an additional binary sensor.""" entry = configure_integration(hass) test_gateway = HomeControlMockLight() @@ -35,18 +33,8 @@ async def test_light_without_binary_sensor(hass: HomeAssistant) -> None: await hass.async_block_till_done() state = hass.states.get(f"{DOMAIN}.test") - assert state is not None - assert state.state == STATE_ON - assert state.attributes[ATTR_FRIENDLY_NAME] == "Test" - assert state.attributes[ATTR_COLOR_MODE] == ColorMode.BRIGHTNESS - assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.BRIGHTNESS] - assert state.attributes[ATTR_BRIGHTNESS] == round( - test_gateway.devices["Test"] - .multi_level_switch_property["devolo.Dimmer:Test"] - .value - / 100 - * 255 - ) + assert state == snapshot + assert entity_registry.async_get(f"{DOMAIN}.test") == snapshot # Emulate websocket message: brightness changed test_gateway.publisher.dispatch("Test", ("devolo.Dimmer:Test", 0.0)) @@ -96,7 +84,9 @@ async def test_light_without_binary_sensor(hass: HomeAssistant) -> None: assert hass.states.get(f"{DOMAIN}.test").state == STATE_UNAVAILABLE -async def test_light_with_binary_sensor(hass: HomeAssistant) -> None: +async def test_light_with_binary_sensor( + hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion +) -> None: """Test setup and state change of a light device that has an additional binary sensor.""" entry = configure_integration(hass) test_gateway = HomeControlMockLight() @@ -111,8 +101,8 @@ async def test_light_with_binary_sensor(hass: HomeAssistant) -> None: await hass.async_block_till_done() state = hass.states.get(f"{DOMAIN}.test") - assert state is not None - assert state.state == STATE_ON + assert state == snapshot + assert entity_registry.async_get(f"{DOMAIN}.test") == snapshot # Emulate websocket message: brightness changed test_gateway.publisher.dispatch("Test", ("devolo.Dimmer:Test", 0.0)) diff --git a/tests/components/devolo_home_control/test_sensor.py b/tests/components/devolo_home_control/test_sensor.py index 9746fca6b6f8..afc4289fccf7 100644 --- a/tests/components/devolo_home_control/test_sensor.py +++ b/tests/components/devolo_home_control/test_sensor.py @@ -1,19 +1,10 @@ """Tests for the devolo Home Control sensor platform.""" from unittest.mock import patch -from homeassistant.components.sensor import ( - ATTR_STATE_CLASS, - DOMAIN, - SensorDeviceClass, - SensorStateClass, -) -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_UNIT_OF_MEASUREMENT, - PERCENTAGE, - STATE_UNAVAILABLE, - EntityCategory, -) +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.sensor import DOMAIN +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -21,7 +12,9 @@ from . import configure_integration from .mocks import HomeControlMock, HomeControlMockConsumption, HomeControlMockSensor -async def test_temperature_sensor(hass: HomeAssistant) -> None: +async def test_temperature_sensor( + hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion +) -> None: """Test setup of a temperature sensor device.""" entry = configure_integration(hass) test_gateway = HomeControlMockSensor() @@ -33,18 +26,12 @@ async def test_temperature_sensor(hass: HomeAssistant) -> None: await hass.async_block_till_done() state = hass.states.get(f"{DOMAIN}.test_temperature") - assert state is not None - assert state.state == str( - test_gateway.devices["Test"] - .multi_level_sensor_property["devolo.MultiLevelSensor:Test"] - .value - ) - assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT - assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.TEMPERATURE + assert state == snapshot + assert entity_registry.async_get(f"{DOMAIN}.test_temperature") == snapshot async def test_battery_sensor( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion ) -> None: """Test setup and state change of a battery sensor device.""" entry = configure_integration(hass) @@ -58,15 +45,8 @@ async def test_battery_sensor( await hass.async_block_till_done() state = hass.states.get(f"{DOMAIN}.test_battery_level") - assert state is not None - assert state.state == str(test_gateway.devices["Test"].battery_level) - assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT - assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == PERCENTAGE - assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.BATTERY - assert ( - entity_registry.async_get(f"{DOMAIN}.test_battery_level").entity_category - is EntityCategory.DIAGNOSTIC - ) + assert state == snapshot + assert entity_registry.async_get(f"{DOMAIN}.test_battery_level") == snapshot # Emulate websocket message: value changed test_gateway.publisher.dispatch("Test", ("Test", 10, "battery_level")) @@ -74,7 +54,9 @@ async def test_battery_sensor( assert hass.states.get(f"{DOMAIN}.test_battery_level").state == "10" -async def test_consumption_sensor(hass: HomeAssistant) -> None: +async def test_consumption_sensor( + hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion +) -> None: """Test setup and state change of a consumption sensor device.""" entry = configure_integration(hass) test_gateway = HomeControlMockConsumption() @@ -86,20 +68,12 @@ async def test_consumption_sensor(hass: HomeAssistant) -> None: await hass.async_block_till_done() state = hass.states.get(f"{DOMAIN}.test_current_consumption") - assert state is not None - assert state.state == str( - test_gateway.devices["Test"].consumption_property["devolo.Meter:Test"].current - ) - assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT - assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.POWER + assert state == snapshot + assert entity_registry.async_get(f"{DOMAIN}.test_current_consumption") == snapshot state = hass.states.get(f"{DOMAIN}.test_total_consumption") - assert state is not None - assert state.state == str( - test_gateway.devices["Test"].consumption_property["devolo.Meter:Test"].total - ) - assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.TOTAL_INCREASING - assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.ENERGY + assert state == snapshot + assert entity_registry.async_get(f"{DOMAIN}.test_total_consumption") == snapshot # Emulate websocket message: value changed test_gateway.devices["Test"].consumption_property["devolo.Meter:Test"].total = 50.0 diff --git a/tests/components/devolo_home_control/test_siren.py b/tests/components/devolo_home_control/test_siren.py index 7aee5b53e429..5e72f7711122 100644 --- a/tests/components/devolo_home_control/test_siren.py +++ b/tests/components/devolo_home_control/test_siren.py @@ -2,22 +2,21 @@ from unittest.mock import patch import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.siren import DOMAIN -from homeassistant.const import ( - ATTR_FRIENDLY_NAME, - STATE_OFF, - STATE_ON, - STATE_UNAVAILABLE, -) +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from . import configure_integration from .mocks import HomeControlMock, HomeControlMockSiren @pytest.mark.usefixtures("mock_zeroconf") -async def test_siren(hass: HomeAssistant) -> None: +async def test_siren( + hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion +) -> None: """Test setup and state change of a siren device.""" entry = configure_integration(hass) test_gateway = HomeControlMockSiren() @@ -30,9 +29,8 @@ async def test_siren(hass: HomeAssistant) -> None: await hass.async_block_till_done() state = hass.states.get(f"{DOMAIN}.test") - assert state is not None - assert state.state == STATE_OFF - assert state.attributes[ATTR_FRIENDLY_NAME] == "Test" + assert state == snapshot + assert entity_registry.async_get(f"{DOMAIN}.test") == snapshot # Emulate websocket message: sensor turned on test_gateway.publisher.dispatch("Test", ("devolo.SirenMultiLevelSwitch:Test", 1)) @@ -47,7 +45,9 @@ async def test_siren(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("mock_zeroconf") -async def test_siren_switching(hass: HomeAssistant) -> None: +async def test_siren_switching( + hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion +) -> None: """Test setup and state change via switching of a siren device.""" entry = configure_integration(hass) test_gateway = HomeControlMockSiren() @@ -60,8 +60,8 @@ async def test_siren_switching(hass: HomeAssistant) -> None: await hass.async_block_till_done() state = hass.states.get(f"{DOMAIN}.test") - assert state is not None - assert state.state == STATE_OFF + assert state == snapshot + assert entity_registry.async_get(f"{DOMAIN}.test") == snapshot with patch( "devolo_home_control_api.properties.multi_level_switch_property.MultiLevelSwitchProperty.set" @@ -98,7 +98,9 @@ async def test_siren_switching(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("mock_zeroconf") -async def test_siren_change_default_tone(hass: HomeAssistant) -> None: +async def test_siren_change_default_tone( + hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion +) -> None: """Test changing the default tone on message.""" entry = configure_integration(hass) test_gateway = HomeControlMockSiren() @@ -111,7 +113,8 @@ async def test_siren_change_default_tone(hass: HomeAssistant) -> None: await hass.async_block_till_done() state = hass.states.get(f"{DOMAIN}.test") - assert state is not None + assert state == snapshot + assert entity_registry.async_get(f"{DOMAIN}.test") == snapshot with patch( "devolo_home_control_api.properties.multi_level_switch_property.MultiLevelSwitchProperty.set" diff --git a/tests/components/devolo_home_control/test_switch.py b/tests/components/devolo_home_control/test_switch.py index 8f1607024086..9216768b9c35 100644 --- a/tests/components/devolo_home_control/test_switch.py +++ b/tests/components/devolo_home_control/test_switch.py @@ -1,22 +1,26 @@ """Tests for the devolo Home Control switch platform.""" from unittest.mock import patch +from syrupy.assertion import SnapshotAssertion + from homeassistant.components.switch import DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON, - STATE_OFF, STATE_ON, STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from . import configure_integration from .mocks import HomeControlMock, HomeControlMockSwitch -async def test_switch(hass: HomeAssistant) -> None: +async def test_switch( + hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion +) -> None: """Test setup and state change of a switch device.""" entry = configure_integration(hass) test_gateway = HomeControlMockSwitch() @@ -28,8 +32,8 @@ async def test_switch(hass: HomeAssistant) -> None: await hass.async_block_till_done() state = hass.states.get(f"{DOMAIN}.test") - assert state is not None - assert state.state == STATE_OFF + assert state == snapshot + assert entity_registry.async_get(f"{DOMAIN}.test") == snapshot # Emulate websocket message: switched on test_gateway.devices["Test"].binary_switch_property[ From d2a39e0e6f948b4e11f2026a8593cf432bf15cd0 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 22 May 2023 20:48:56 +0200 Subject: [PATCH 451/822] Update requests to 2.31.0 (#93363) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_test.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 033d87d63c31..0654587d2453 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -43,7 +43,7 @@ pyserial==3.5 python-slugify==4.0.1 pyudev==0.23.2 pyyaml==6.0 -requests==2.29.0 +requests==2.31.0 scapy==2.5.0 sqlalchemy==2.0.15 typing-extensions>=4.5.0,<5.0 diff --git a/pyproject.toml b/pyproject.toml index 6da5817c4bc2..1e69404f4cb5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,7 @@ dependencies = [ "pip>=21.0,<23.2", "python-slugify==4.0.1", "pyyaml==6.0", - "requests==2.29.0", + "requests==2.31.0", "typing-extensions>=4.5.0,<5.0", "ulid-transform==0.7.2", "voluptuous==0.13.1", diff --git a/requirements.txt b/requirements.txt index 7efb44738afe..12533b518474 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,7 +22,7 @@ orjson==3.8.12 pip>=21.0,<23.2 python-slugify==4.0.1 pyyaml==6.0 -requests==2.29.0 +requests==2.31.0 typing-extensions>=4.5.0,<5.0 ulid-transform==0.7.2 voluptuous==0.13.1 diff --git a/requirements_test.txt b/requirements_test.txt index 54086086c388..6a3c3b7e0be8 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -47,5 +47,5 @@ types-python-dateutil==2.8.19.5 types-python-slugify==0.1.2 types-pytz==2022.7.0.0 types-PyYAML==6.0.12.2 -types-requests==2.29.0.0 +types-requests==2.30.0.0 types-toml==0.10.8.1 From 71466e50127b68e0ee63b17401d6bce2e96a72bd Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 22 May 2023 19:55:54 +0000 Subject: [PATCH 452/822] Add new sensors to AccuWeather (#93362) * Add solar irradiance and condition sensors * Use entity_registry_enabled_by_default fixture --- .../components/accuweather/sensor.py | 27 ++ tests/components/accuweather/test_sensor.py | 421 +++++++----------- 2 files changed, 180 insertions(+), 268 deletions(-) diff --git a/homeassistant/components/accuweather/sensor.py b/homeassistant/components/accuweather/sensor.py index 5d0c70de4e15..5a85b4a4c387 100644 --- a/homeassistant/components/accuweather/sensor.py +++ b/homeassistant/components/accuweather/sensor.py @@ -16,6 +16,7 @@ from homeassistant.const import ( CONCENTRATION_PARTS_PER_CUBIC_METER, PERCENTAGE, UV_INDEX, + UnitOfIrradiance, UnitOfLength, UnitOfSpeed, UnitOfTemperature, @@ -104,6 +105,16 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( native_unit_of_measurement=UnitOfTime.HOURS, value_fn=lambda data: cast(float, data), ), + AccuWeatherSensorDescription( + key="LongPhraseDay", + name="Condition day", + value_fn=lambda data: cast(str, data), + ), + AccuWeatherSensorDescription( + key="LongPhraseNight", + name="Condition night", + value_fn=lambda data: cast(str, data), + ), AccuWeatherSensorDescription( key="Mold", icon="mdi:blur", @@ -154,6 +165,22 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( native_unit_of_measurement=UnitOfTemperature.CELSIUS, value_fn=lambda data: cast(float, data[ATTR_VALUE]), ), + AccuWeatherSensorDescription( + key="SolarIrradianceDay", + icon="mdi:weather-sunny", + name="Solar irradiance day", + entity_registry_enabled_default=False, + native_unit_of_measurement=UnitOfIrradiance.WATTS_PER_SQUARE_METER, + value_fn=lambda data: cast(float, data[ATTR_VALUE]), + ), + AccuWeatherSensorDescription( + key="SolarIrradianceNight", + icon="mdi:weather-sunny", + name="Solar irradiance night", + entity_registry_enabled_default=False, + native_unit_of_measurement=UnitOfIrradiance.WATTS_PER_SQUARE_METER, + value_fn=lambda data: cast(float, data[ATTR_VALUE]), + ), AccuWeatherSensorDescription( key="ThunderstormProbabilityDay", icon="mdi:weather-lightning", diff --git a/tests/components/accuweather/test_sensor.py b/tests/components/accuweather/test_sensor.py index f27aa158e0e4..35f86bdb039f 100644 --- a/tests/components/accuweather/test_sensor.py +++ b/tests/components/accuweather/test_sensor.py @@ -2,11 +2,10 @@ from datetime import timedelta from unittest.mock import PropertyMock, patch -from homeassistant.components.accuweather.const import ATTRIBUTION, DOMAIN +from homeassistant.components.accuweather.const import ATTRIBUTION from homeassistant.components.sensor import ( ATTR_OPTIONS, ATTR_STATE_CLASS, - DOMAIN as SENSOR_DOMAIN, SensorDeviceClass, SensorStateClass, ) @@ -20,6 +19,7 @@ from homeassistant.const import ( PERCENTAGE, STATE_UNAVAILABLE, UV_INDEX, + UnitOfIrradiance, UnitOfLength, UnitOfSpeed, UnitOfTemperature, @@ -41,7 +41,9 @@ from tests.common import ( ) -async def test_sensor_without_forecast(hass: HomeAssistant) -> None: +async def test_sensor_without_forecast( + hass: HomeAssistant, entity_registry_enabled_by_default: None +) -> None: """Test states of the sensor without forecast.""" await init_integration(hass) registry = er.async_get(hass) @@ -118,271 +120,6 @@ async def test_sensor_without_forecast(hass: HomeAssistant) -> None: assert entry assert entry.unique_id == "0123456-uvindex" - -async def test_sensor_with_forecast(hass: HomeAssistant) -> None: - """Test states of the sensor with forecast.""" - await init_integration(hass, forecast=True) - registry = er.async_get(hass) - - state = hass.states.get("sensor.home_hours_of_sun_0d") - assert state - assert state.state == "7.2" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_ICON) == "mdi:weather-partly-cloudy" - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTime.HOURS - assert state.attributes.get(ATTR_STATE_CLASS) is None - - entry = registry.async_get("sensor.home_hours_of_sun_0d") - assert entry - assert entry.unique_id == "0123456-hoursofsun-0" - - state = hass.states.get("sensor.home_realfeel_temperature_max_0d") - assert state - assert state.state == "29.8" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.CELSIUS - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE - assert state.attributes.get(ATTR_STATE_CLASS) is None - - entry = registry.async_get("sensor.home_realfeel_temperature_max_0d") - assert entry - - state = hass.states.get("sensor.home_realfeel_temperature_min_0d") - assert state - assert state.state == "15.1" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.CELSIUS - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE - assert state.attributes.get(ATTR_STATE_CLASS) is None - - entry = registry.async_get("sensor.home_realfeel_temperature_min_0d") - assert entry - assert entry.unique_id == "0123456-realfeeltemperaturemin-0" - - state = hass.states.get("sensor.home_thunderstorm_probability_day_0d") - assert state - assert state.state == "40" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_ICON) == "mdi:weather-lightning" - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE - assert state.attributes.get(ATTR_STATE_CLASS) is None - - entry = registry.async_get("sensor.home_thunderstorm_probability_day_0d") - assert entry - assert entry.unique_id == "0123456-thunderstormprobabilityday-0" - - state = hass.states.get("sensor.home_thunderstorm_probability_night_0d") - assert state - assert state.state == "40" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_ICON) == "mdi:weather-lightning" - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE - assert state.attributes.get(ATTR_STATE_CLASS) is None - - entry = registry.async_get("sensor.home_thunderstorm_probability_night_0d") - assert entry - assert entry.unique_id == "0123456-thunderstormprobabilitynight-0" - - state = hass.states.get("sensor.home_uv_index_0d") - assert state - assert state.state == "5" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_ICON) == "mdi:weather-sunny" - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UV_INDEX - assert state.attributes.get("level") == "moderate" - assert state.attributes.get(ATTR_STATE_CLASS) is None - - entry = registry.async_get("sensor.home_uv_index_0d") - assert entry - assert entry.unique_id == "0123456-uvindex-0" - - state = hass.states.get("sensor.home_air_quality_0d") - assert state - assert state.state == "good" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_ICON) == "mdi:air-filter" - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENUM - assert state.attributes.get(ATTR_OPTIONS) == [ - "good", - "hazardous", - "high", - "low", - "moderate", - "unhealthy", - ] - - entry = registry.async_get("sensor.home_air_quality_0d") - assert entry - assert entry.unique_id == "0123456-airquality-0" - - -async def test_sensor_disabled(hass: HomeAssistant) -> None: - """Test sensor disabled by default.""" - await init_integration(hass) - registry = er.async_get(hass) - - entry = registry.async_get("sensor.home_apparent_temperature") - assert entry - assert entry.unique_id == "0123456-apparenttemperature" - assert entry.disabled - assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION - - # Test enabling entity - updated_entry = registry.async_update_entity( - entry.entity_id, **{"disabled_by": None} - ) - - assert updated_entry != entry - assert updated_entry.disabled is False - - -async def test_sensor_enabled_without_forecast(hass: HomeAssistant) -> None: - """Test enabling an advanced sensor.""" - registry = er.async_get(hass) - - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "0123456-apparenttemperature", - suggested_object_id="home_apparent_temperature", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "0123456-cloudcover", - suggested_object_id="home_cloud_cover", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "0123456-dewpoint", - suggested_object_id="home_dew_point", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "0123456-realfeeltemperatureshade", - suggested_object_id="home_realfeel_temperature_shade", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "0123456-wetbulbtemperature", - suggested_object_id="home_wet_bulb_temperature", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "0123456-wind", - suggested_object_id="home_wind", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "0123456-windchilltemperature", - suggested_object_id="home_wind_chill_temperature", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "0123456-windgust", - suggested_object_id="home_wind_gust", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "0123456-cloudcoverday-0", - suggested_object_id="home_cloud_cover_day_0d", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "0123456-cloudcovernight-0", - suggested_object_id="home_cloud_cover_night_0d", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "0123456-grass-0", - suggested_object_id="home_grass_pollen_0d", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "0123456-mold-0", - suggested_object_id="home_mold_pollen_0d", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "0123456-ragweed-0", - suggested_object_id="home_ragweed_pollen_0d", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "0123456-realfeeltemperatureshademax-0", - suggested_object_id="home_realfeel_temperature_shade_max_0d", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "0123456-realfeeltemperatureshademin-0", - suggested_object_id="home_realfeel_temperature_shade_min_0d", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "0123456-tree-0", - suggested_object_id="home_tree_pollen_0d", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "0123456-windgustday-0", - suggested_object_id="home_wind_gust_day_0d", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "0123456-windgustnight-0", - suggested_object_id="home_wind_gust_night_0d", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "0123456-windday-0", - suggested_object_id="home_wind_day_0d", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "0123456-windnight-0", - suggested_object_id="home_wind_night_0d", - disabled_by=None, - ) - - await init_integration(hass, forecast=True) - state = hass.states.get("sensor.home_apparent_temperature") assert state assert state.state == "22.8" @@ -487,6 +224,101 @@ async def test_sensor_enabled_without_forecast(hass: HomeAssistant) -> None: assert entry assert entry.unique_id == "0123456-wind" + +async def test_sensor_with_forecast( + hass: HomeAssistant, entity_registry_enabled_by_default: None +) -> None: + """Test states of the sensor with forecast.""" + await init_integration(hass, forecast=True) + registry = er.async_get(hass) + + state = hass.states.get("sensor.home_hours_of_sun_0d") + assert state + assert state.state == "7.2" + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert state.attributes.get(ATTR_ICON) == "mdi:weather-partly-cloudy" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTime.HOURS + assert state.attributes.get(ATTR_STATE_CLASS) is None + + entry = registry.async_get("sensor.home_hours_of_sun_0d") + assert entry + assert entry.unique_id == "0123456-hoursofsun-0" + + state = hass.states.get("sensor.home_realfeel_temperature_max_0d") + assert state + assert state.state == "29.8" + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.CELSIUS + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE + assert state.attributes.get(ATTR_STATE_CLASS) is None + + entry = registry.async_get("sensor.home_realfeel_temperature_max_0d") + assert entry + + state = hass.states.get("sensor.home_realfeel_temperature_min_0d") + assert state + assert state.state == "15.1" + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.CELSIUS + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE + assert state.attributes.get(ATTR_STATE_CLASS) is None + + entry = registry.async_get("sensor.home_realfeel_temperature_min_0d") + assert entry + assert entry.unique_id == "0123456-realfeeltemperaturemin-0" + + state = hass.states.get("sensor.home_thunderstorm_probability_day_0d") + assert state + assert state.state == "40" + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert state.attributes.get(ATTR_ICON) == "mdi:weather-lightning" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE + assert state.attributes.get(ATTR_STATE_CLASS) is None + + entry = registry.async_get("sensor.home_thunderstorm_probability_day_0d") + assert entry + assert entry.unique_id == "0123456-thunderstormprobabilityday-0" + + state = hass.states.get("sensor.home_thunderstorm_probability_night_0d") + assert state + assert state.state == "40" + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert state.attributes.get(ATTR_ICON) == "mdi:weather-lightning" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE + assert state.attributes.get(ATTR_STATE_CLASS) is None + + entry = registry.async_get("sensor.home_thunderstorm_probability_night_0d") + assert entry + assert entry.unique_id == "0123456-thunderstormprobabilitynight-0" + + state = hass.states.get("sensor.home_uv_index_0d") + assert state + assert state.state == "5" + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert state.attributes.get(ATTR_ICON) == "mdi:weather-sunny" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UV_INDEX + assert state.attributes.get("level") == "moderate" + assert state.attributes.get(ATTR_STATE_CLASS) is None + + entry = registry.async_get("sensor.home_uv_index_0d") + assert entry + assert entry.unique_id == "0123456-uvindex-0" + + state = hass.states.get("sensor.home_air_quality_0d") + assert state + assert state.state == "good" + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert state.attributes.get(ATTR_ICON) == "mdi:air-filter" + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENUM + assert state.attributes.get(ATTR_OPTIONS) == [ + "good", + "hazardous", + "high", + "low", + "moderate", + "unhealthy", + ] + state = hass.states.get("sensor.home_cloud_cover_day_0d") assert state assert state.state == "58" @@ -662,6 +494,59 @@ async def test_sensor_enabled_without_forecast(hass: HomeAssistant) -> None: assert entry assert entry.unique_id == "0123456-windgustnight-0" + entry = registry.async_get("sensor.home_air_quality_0d") + assert entry + assert entry.unique_id == "0123456-airquality-0" + + state = hass.states.get("sensor.home_solar_irradiance_day_0d") + assert state + assert state.state == "7447.1" + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert state.attributes.get(ATTR_ICON) == "mdi:weather-sunny" + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfIrradiance.WATTS_PER_SQUARE_METER + ) + + entry = registry.async_get("sensor.home_solar_irradiance_day_0d") + assert entry + assert entry.unique_id == "0123456-solarirradianceday-0" + + state = hass.states.get("sensor.home_solar_irradiance_night_0d") + assert state + assert state.state == "271.6" + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert state.attributes.get(ATTR_ICON) == "mdi:weather-sunny" + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfIrradiance.WATTS_PER_SQUARE_METER + ) + + entry = registry.async_get("sensor.home_solar_irradiance_night_0d") + assert entry + assert entry.unique_id == "0123456-solarirradiancenight-0" + + state = hass.states.get("sensor.home_condition_day_0d") + assert state + assert ( + state.state + == "Clouds and sunshine with a couple of showers and a thunderstorm around late this afternoon" + ) + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + + entry = registry.async_get("sensor.home_condition_day_0d") + assert entry + assert entry.unique_id == "0123456-longphraseday-0" + + state = hass.states.get("sensor.home_condition_night_0d") + assert state + assert state.state == "Partly cloudy" + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + + entry = registry.async_get("sensor.home_condition_night_0d") + assert entry + assert entry.unique_id == "0123456-longphrasenight-0" + async def test_availability(hass: HomeAssistant) -> None: """Ensure that we mark the entities unavailable correctly when service is offline.""" From af3bbdee822d8853beaf5d4207fea25c05a3f72e Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 May 2023 22:15:02 +0200 Subject: [PATCH 453/822] Plugwise add value_fn for switch (#93345) * Plugwise add value_fn for switch * Skip none as suggested --- homeassistant/components/plugwise/switch.py | 43 ++++++++++++++++----- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/plugwise/switch.py b/homeassistant/components/plugwise/switch.py index d6f8a420cc84..4204ab5a4d9d 100644 --- a/homeassistant/components/plugwise/switch.py +++ b/homeassistant/components/plugwise/switch.py @@ -1,8 +1,12 @@ """Plugwise Switch component for HomeAssistant.""" from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass from typing import Any +from plugwise import SmileSwitches + from homeassistant.components.switch import ( SwitchDeviceClass, SwitchEntity, @@ -18,29 +22,46 @@ from .coordinator import PlugwiseDataUpdateCoordinator from .entity import PlugwiseEntity from .util import plugwise_command -SWITCHES: tuple[SwitchEntityDescription, ...] = ( - SwitchEntityDescription( + +@dataclass +class PlugwiseSwitchBaseMixin: + """Mixin for required Plugwise switch description keys.""" + + value_fn: Callable[[SmileSwitches], bool] + + +@dataclass +class PlugwiseSwitchEntityDescription(SwitchEntityDescription, PlugwiseSwitchBaseMixin): + """Describes Plugwise switch entity.""" + + +SWITCHES: tuple[PlugwiseSwitchEntityDescription, ...] = ( + PlugwiseSwitchEntityDescription( key="dhw_cm_switch", translation_key="dhw_cm_switch", icon="mdi:water-plus", entity_category=EntityCategory.CONFIG, + value_fn=lambda data: data["dhw_cm_switch"], ), - SwitchEntityDescription( + PlugwiseSwitchEntityDescription( key="lock", translation_key="lock", icon="mdi:lock", entity_category=EntityCategory.CONFIG, + value_fn=lambda data: data["lock"], ), - SwitchEntityDescription( + PlugwiseSwitchEntityDescription( key="relay", translation_key="relay", device_class=SwitchDeviceClass.SWITCH, + value_fn=lambda data: data["relay"], ), - SwitchEntityDescription( + PlugwiseSwitchEntityDescription( key="cooling_ena_switch", name="Cooling", icon="mdi:snowflake-thermometer", entity_category=EntityCategory.CONFIG, + value_fn=lambda data: data["cooling_ena_switch"], ), ) @@ -54,8 +75,10 @@ async def async_setup_entry( coordinator = hass.data[DOMAIN][config_entry.entry_id] entities: list[PlugwiseSwitchEntity] = [] for device_id, device in coordinator.data.devices.items(): + if not (switches := device.get("switches")): + continue for description in SWITCHES: - if "switches" not in device or description.key not in device["switches"]: + if description.key not in switches: continue entities.append(PlugwiseSwitchEntity(coordinator, device_id, description)) async_add_entities(entities) @@ -64,11 +87,13 @@ async def async_setup_entry( class PlugwiseSwitchEntity(PlugwiseEntity, SwitchEntity): """Representation of a Plugwise plug.""" + entity_description: PlugwiseSwitchEntityDescription + def __init__( self, coordinator: PlugwiseDataUpdateCoordinator, device_id: str, - description: SwitchEntityDescription, + description: PlugwiseSwitchEntityDescription, ) -> None: """Set up the Plugwise API.""" super().__init__(coordinator, device_id) @@ -76,9 +101,9 @@ class PlugwiseSwitchEntity(PlugwiseEntity, SwitchEntity): self._attr_unique_id = f"{device_id}-{description.key}" @property - def is_on(self) -> bool | None: + def is_on(self) -> bool: """Return True if entity is on.""" - return self.device["switches"].get(self.entity_description.key) + return self.entity_description.value_fn(self.device["switches"]) @plugwise_command async def async_turn_on(self, **kwargs: Any) -> None: From 400263cf0477a8eefd50699d93b08f9c30eac5ec Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 May 2023 23:02:42 +0200 Subject: [PATCH 454/822] Plugwise binary_sensor typing improvement (#93371) Typing miss correction (spotted by epenet) --- homeassistant/components/plugwise/binary_sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/plugwise/binary_sensor.py b/homeassistant/components/plugwise/binary_sensor.py index ac7a4f796a26..956dd7f36daa 100644 --- a/homeassistant/components/plugwise/binary_sensor.py +++ b/homeassistant/components/plugwise/binary_sensor.py @@ -152,7 +152,7 @@ class PlugwiseBinarySensorEntity(PlugwiseEntity, BinarySensorEntity): self._attr_unique_id = f"{device_id}-{description.key}" @property - def is_on(self) -> bool | None: + def is_on(self) -> bool: """Return true if the binary sensor is on.""" return self.entity_description.value_fn(self.device["binary_sensors"]) From 657d285e8fe346908a1eb618dd4d987bb3190236 Mon Sep 17 00:00:00 2001 From: Dominik Date: Mon, 22 May 2023 23:15:01 +0200 Subject: [PATCH 455/822] Bump glances_api to 0.4.2 (#93352) --- homeassistant/components/glances/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/glances/manifest.json b/homeassistant/components/glances/manifest.json index b59fc390a6b6..767a27ffdfdc 100644 --- a/homeassistant/components/glances/manifest.json +++ b/homeassistant/components/glances/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/glances", "iot_class": "local_polling", "loggers": ["glances_api"], - "requirements": ["glances_api==0.4.1"] + "requirements": ["glances_api==0.4.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index c685edaa5b73..785398bdf48c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -799,7 +799,7 @@ gios==3.1.0 gitterpy==0.1.7 # homeassistant.components.glances -glances_api==0.4.1 +glances_api==0.4.2 # homeassistant.components.goalzero goalzero==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8719e908495c..7370d2b45fac 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -621,7 +621,7 @@ getmac==0.8.2 gios==3.1.0 # homeassistant.components.glances -glances_api==0.4.1 +glances_api==0.4.2 # homeassistant.components.goalzero goalzero==0.2.1 From b754f03eb1339b64be61c510f002980e57ebb444 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 23 May 2023 10:44:02 +0200 Subject: [PATCH 456/822] Add WS command for getting a config entry (#93387) * Add WS command for getting a config entry * Update tests --- .../components/config/config_entries.py | 29 +++++- .../components/bluetooth/test_config_flow.py | 6 +- .../components/config/test_config_entries.py | 95 ++++++++++++------- tests/components/shelly/test_config_flow.py | 6 +- 4 files changed, 95 insertions(+), 41 deletions(-) diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index c6fd4003156c..2e1ea72dfb45 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -41,8 +41,9 @@ async def async_setup(hass): hass.http.register_view(OptionManagerFlowIndexView(hass.config_entries.options)) hass.http.register_view(OptionManagerFlowResourceView(hass.config_entries.options)) - websocket_api.async_register_command(hass, config_entries_get) + websocket_api.async_register_command(hass, config_entries_get_matching) websocket_api.async_register_command(hass, config_entry_disable) + websocket_api.async_register_command(hass, config_entry_get) websocket_api.async_register_command(hass, config_entry_update) websocket_api.async_register_command(hass, config_entries_subscribe) websocket_api.async_register_command(hass, config_entries_progress) @@ -284,6 +285,28 @@ def get_entry( return entry +@websocket_api.require_admin +@websocket_api.websocket_command( + { + "type": "config_entries/get", + "entry_id": str, + } +) +@websocket_api.async_response +async def config_entry_get( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Update config entry.""" + entry = get_entry(hass, connection, msg["entry_id"], msg["id"]) + if entry is None: + return + + result = {"config_entry": entry_json(entry)} + connection.send_result(msg["id"], result) + + @websocket_api.require_admin @websocket_api.websocket_command( { @@ -409,13 +432,13 @@ async def ignore_config_flow( @websocket_api.websocket_command( { - vol.Required("type"): "config_entries/get", + vol.Required("type"): "config_entries/get_matching", vol.Optional("type_filter"): vol.All(cv.ensure_list, [str]), vol.Optional("domain"): str, } ) @websocket_api.async_response -async def config_entries_get( +async def config_entries_get_matching( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], diff --git a/tests/components/bluetooth/test_config_flow.py b/tests/components/bluetooth/test_config_flow.py index 1489f349cf40..16fe9602d39e 100644 --- a/tests/components/bluetooth/test_config_flow.py +++ b/tests/components/bluetooth/test_config_flow.py @@ -36,7 +36,7 @@ async def test_options_flow_disabled_not_setup( await ws_client.send_json( { "id": 5, - "type": "config_entries/get", + "type": "config_entries/get_matching", "domain": "bluetooth", } ) @@ -370,7 +370,7 @@ async def test_options_flow_disabled_macos( await ws_client.send_json( { "id": 5, - "type": "config_entries/get", + "type": "config_entries/get_matching", "domain": "bluetooth", } ) @@ -403,7 +403,7 @@ async def test_options_flow_enabled_linux( await ws_client.send_json( { "id": 5, - "type": "config_entries/get", + "type": "config_entries/get_matching", "domain": "bluetooth", } ) diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index f861d887b99e..d54abe772769 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -965,6 +965,55 @@ async def test_options_flow_with_invalid_data(hass: HomeAssistant, client) -> No } +async def test_get(hass: HomeAssistant, hass_ws_client: WebSocketGenerator) -> None: + """Test that we can get a config entry.""" + assert await async_setup_component(hass, "config", {}) + ws_client = await hass_ws_client(hass) + + entry = MockConfigEntry(domain="demo", state=core_ce.ConfigEntryState.LOADED) + entry.add_to_hass(hass) + + assert entry.pref_disable_new_entities is False + assert entry.pref_disable_polling is False + + await ws_client.send_json_auto_id( + { + "type": "config_entries/get", + "entry_id": entry.entry_id, + } + ) + response = await ws_client.receive_json() + + assert response["success"] + assert response["result"]["config_entry"] == { + "disabled_by": None, + "domain": "demo", + "entry_id": entry.entry_id, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "reason": None, + "source": "user", + "state": "loaded", + "supports_options": False, + "supports_remove_device": False, + "supports_unload": False, + "title": "Mock Title", + } + + await ws_client.send_json_auto_id( + { + "type": "config_entries/get", + "entry_id": "blah", + } + ) + response = await ws_client.receive_json() + assert not response["success"] + assert response["error"] == { + "code": "not_found", + "message": "Config entry not found", + } + + async def test_update_prefrences( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -1209,7 +1258,7 @@ async def test_ignore_flow_nonexisting( assert response["error"]["code"] == "not_found" -async def test_get_entries_ws( +async def test_get_matching_entries_ws( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, clear_handlers ) -> None: """Test get entries with the websocket api.""" @@ -1260,14 +1309,8 @@ async def test_get_entries_ws( ws_client = await hass_ws_client(hass) - await ws_client.send_json( - { - "id": 5, - "type": "config_entries/get", - } - ) + await ws_client.send_json_auto_id({"type": "config_entries/get_matching"}) response = await ws_client.receive_json() - assert response["id"] == 5 assert response["result"] == [ { "disabled_by": None, @@ -1341,16 +1384,14 @@ async def test_get_entries_ws( }, ] - await ws_client.send_json( + await ws_client.send_json_auto_id( { - "id": 6, - "type": "config_entries/get", + "type": "config_entries/get_matching", "domain": "comp1", "type_filter": "hub", } ) response = await ws_client.receive_json() - assert response["id"] == 6 assert response["result"] == [ { "disabled_by": None, @@ -1368,15 +1409,13 @@ async def test_get_entries_ws( } ] - await ws_client.send_json( + await ws_client.send_json_auto_id( { - "id": 7, - "type": "config_entries/get", + "type": "config_entries/get_matching", "type_filter": ["service", "device"], } ) response = await ws_client.receive_json() - assert response["id"] == 7 assert response["result"] == [ { "disabled_by": None, @@ -1408,15 +1447,13 @@ async def test_get_entries_ws( }, ] - await ws_client.send_json( + await ws_client.send_json_auto_id( { - "id": 8, - "type": "config_entries/get", + "type": "config_entries/get_matching", "type_filter": "hub", } ) response = await ws_client.receive_json() - assert response["id"] == 8 assert response["result"] == [ { "disabled_by": None, @@ -1453,16 +1490,14 @@ async def test_get_entries_ws( "homeassistant.components.config.config_entries.async_get_integrations", return_value={"any": IntegrationNotFound("any")}, ): - await ws_client.send_json( + await ws_client.send_json_auto_id( { - "id": 9, - "type": "config_entries/get", + "type": "config_entries/get_matching", "type_filter": "hub", } ) response = await ws_client.receive_json() - assert response["id"] == 9 assert response["result"] == [ { "disabled_by": None, @@ -1541,16 +1576,14 @@ async def test_get_entries_ws( "homeassistant.components.config.config_entries.async_get_integrations", return_value={"any": IntegrationNotFound("any")}, ): - await ws_client.send_json( + await ws_client.send_json_auto_id( { - "id": 10, - "type": "config_entries/get", + "type": "config_entries/get_matching", "type_filter": ["helper"], } ) response = await ws_client.receive_json() - assert response["id"] == 10 assert response["result"] == [] # Verify we raise if something really goes wrong @@ -1559,16 +1592,14 @@ async def test_get_entries_ws( "homeassistant.components.config.config_entries.async_get_integrations", return_value={"any": Exception()}, ): - await ws_client.send_json( + await ws_client.send_json_auto_id( { - "id": 11, - "type": "config_entries/get", + "type": "config_entries/get_matching", "type_filter": ["device", "hub", "service"], } ) response = await ws_client.receive_json() - assert response["id"] == 11 assert response["success"] is False diff --git a/tests/components/shelly/test_config_flow.py b/tests/components/shelly/test_config_flow.py index 621885bef085..210db65dfda8 100644 --- a/tests/components/shelly/test_config_flow.py +++ b/tests/components/shelly/test_config_flow.py @@ -858,7 +858,7 @@ async def test_options_flow_disabled_gen_1( await ws_client.send_json( { "id": 5, - "type": "config_entries/get", + "type": "config_entries/get_matching", "domain": "shelly", } ) @@ -879,7 +879,7 @@ async def test_options_flow_enabled_gen_2( await ws_client.send_json( { "id": 5, - "type": "config_entries/get", + "type": "config_entries/get_matching", "domain": "shelly", } ) @@ -900,7 +900,7 @@ async def test_options_flow_disabled_sleepy_gen_2( await ws_client.send_json( { "id": 5, - "type": "config_entries/get", + "type": "config_entries/get_matching", "domain": "shelly", } ) From e100bcfaeae6a266a1d014d9ba374fa1d9690ffa Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Tue, 23 May 2023 10:19:29 +0100 Subject: [PATCH 457/822] Better handling of source sensor unavailability in Riemman Integration (#93137) * refactor and increase coverage * fix log order --- .../components/integration/sensor.py | 70 +++++++++----- tests/components/integration/test_sensor.py | 91 +++++++++++++++++-- 2 files changed, 126 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index d55a11366467..64d83506ad95 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -197,25 +197,23 @@ class IntegrationSensor(RestoreEntity, SensorEntity): old_state: State | None = event.data.get("old_state") new_state: State | None = event.data.get("new_state") - if ( - source_state := self.hass.states.get(self._sensor_source_id) - ) is None or source_state.state == STATE_UNAVAILABLE: - self._attr_available = False - self.async_write_ha_state() - return - - self._attr_available = True - - if new_state is None or new_state.state in ( - STATE_UNKNOWN, - STATE_UNAVAILABLE, - ): - return - # We may want to update our state before an early return, # based on the source sensor's unit_of_measurement # or device_class. update_state = False + + if ( + source_state := self.hass.states.get(self._sensor_source_id) + ) is None or source_state.state == STATE_UNAVAILABLE: + self._attr_available = False + update_state = True + else: + self._attr_available = True + + if old_state is None or new_state is None: + # we can't calculate the elapsed time, so we can't calculate the integral + return + unit = new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) if unit is not None: new_unit_of_measurement = self._unit(unit) @@ -235,31 +233,53 @@ class IntegrationSensor(RestoreEntity, SensorEntity): if update_state: self.async_write_ha_state() - if old_state is None or old_state.state in ( - STATE_UNKNOWN, - STATE_UNAVAILABLE, - ): - return - try: # integration as the Riemann integral of previous measures. - area = Decimal(0) elapsed_time = ( new_state.last_updated - old_state.last_updated ).total_seconds() - if self._method == METHOD_TRAPEZOIDAL: + if ( + self._method == METHOD_TRAPEZOIDAL + and new_state.state + not in ( + STATE_UNKNOWN, + STATE_UNAVAILABLE, + ) + and old_state.state + not in ( + STATE_UNKNOWN, + STATE_UNAVAILABLE, + ) + ): area = ( (Decimal(new_state.state) + Decimal(old_state.state)) * Decimal(elapsed_time) / 2 ) - elif self._method == METHOD_LEFT: + elif self._method == METHOD_LEFT and old_state.state not in ( + STATE_UNKNOWN, + STATE_UNAVAILABLE, + ): area = Decimal(old_state.state) * Decimal(elapsed_time) - elif self._method == METHOD_RIGHT: + elif self._method == METHOD_RIGHT and new_state.state not in ( + STATE_UNKNOWN, + STATE_UNAVAILABLE, + ): area = Decimal(new_state.state) * Decimal(elapsed_time) + else: + _LOGGER.debug( + "Could not apply method %s to %s -> %s", + self._method, + old_state.state, + new_state.state, + ) + return integral = area / (self._unit_prefix * self._unit_time) + _LOGGER.debug( + "area = %s, integral = %s state = %s", area, integral, self._state + ) assert isinstance(integral, Decimal) except ValueError as err: _LOGGER.warning("While calculating integration: %s", err) diff --git a/tests/components/integration/test_sensor.py b/tests/components/integration/test_sensor.py index 93da55c51a48..b2ad0b36b68e 100644 --- a/tests/components/integration/test_sensor.py +++ b/tests/components/integration/test_sensor.py @@ -2,6 +2,8 @@ from datetime import timedelta from unittest.mock import patch +import pytest + from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, @@ -20,7 +22,8 @@ import homeassistant.util.dt as dt_util from tests.common import mock_restore_cache -async def test_state(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("method", ["trapezoidal", "left", "right"]) +async def test_state(hass: HomeAssistant, method) -> None: """Test integration sensor state.""" config = { "sensor": { @@ -28,6 +31,7 @@ async def test_state(hass: HomeAssistant) -> None: "name": "integration", "source": "sensor.power", "round": 2, + "method": method, } } @@ -46,8 +50,8 @@ async def test_state(hass: HomeAssistant) -> None: assert state.attributes.get("state_class") is SensorStateClass.TOTAL assert "device_class" not in state.attributes - future_now = dt_util.utcnow() + timedelta(seconds=3600) - with patch("homeassistant.util.dt.utcnow", return_value=future_now): + now += timedelta(seconds=3600) + with patch("homeassistant.util.dt.utcnow", return_value=now): hass.states.async_set( entity_id, 1, @@ -69,6 +73,62 @@ async def test_state(hass: HomeAssistant) -> None: assert state.attributes.get("device_class") == SensorDeviceClass.ENERGY assert state.attributes.get("state_class") is SensorStateClass.TOTAL + # 1 hour after last update, power sensor is unavailable + now += timedelta(seconds=3600) + with patch("homeassistant.util.dt.utcnow", return_value=now): + hass.states.async_set( + entity_id, + STATE_UNAVAILABLE, + { + "device_class": SensorDeviceClass.POWER, + ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT, + }, + force_update=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.integration") + assert state.state == STATE_UNAVAILABLE + + # 1 hour after last update, power sensor is back to normal at 2 KiloWatts and stays for 1 hour += 2kWh + now += timedelta(seconds=3600) + with patch("homeassistant.util.dt.utcnow", return_value=now): + hass.states.async_set( + entity_id, + 2, + { + "device_class": SensorDeviceClass.POWER, + ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT, + }, + force_update=True, + ) + await hass.async_block_till_done() + state = hass.states.get("sensor.integration") + assert ( + round(float(state.state), config["sensor"]["round"]) == 3.0 + if method == "right" + else 1.0 + ) + + now += timedelta(seconds=3600) + with patch("homeassistant.util.dt.utcnow", return_value=now): + hass.states.async_set( + entity_id, + 2, + { + "device_class": SensorDeviceClass.POWER, + ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT, + }, + force_update=True, + ) + await hass.async_block_till_done() + state = hass.states.get("sensor.integration") + assert ( + round(float(state.state), config["sensor"]["round"]) == 5.0 + if method == "right" + else 3.0 + ) + async def test_restore_state(hass: HomeAssistant) -> None: """Test integration sensor state is restored correctly.""" @@ -416,13 +476,15 @@ async def test_units(hass: HomeAssistant) -> None: assert new_state.state == STATE_UNAVAILABLE -async def test_device_class(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("method", ["trapezoidal", "left", "right"]) +async def test_device_class(hass: HomeAssistant, method) -> None: """Test integration sensor units using a power source.""" config = { "sensor": { "platform": "integration", "name": "integration", "source": "sensor.power", + "method": method, } } @@ -465,13 +527,15 @@ async def test_device_class(hass: HomeAssistant) -> None: assert state.attributes.get("device_class") == SensorDeviceClass.ENERGY -async def test_calc_errors(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("method", ["trapezoidal", "left", "right"]) +async def test_calc_errors(hass: HomeAssistant, method) -> None: """Test integration sensor units using a power source.""" config = { "sensor": { "platform": "integration", "name": "integration", "source": "sensor.power", + "method": method, } } @@ -479,6 +543,7 @@ async def test_calc_errors(hass: HomeAssistant) -> None: entity_id = config["sensor"]["source"] + now = dt_util.utcnow() hass.states.async_set(entity_id, None, {}) await hass.async_block_till_done() @@ -489,19 +554,25 @@ async def test_calc_errors(hass: HomeAssistant) -> None: assert state.state == STATE_UNKNOWN # Moving from an unknown state to a value is a calc error and should - # not change the value of the Reimann sensor. - hass.states.async_set(entity_id, 0, {"device_class": None}) + # not change the value of the Reimann sensor, unless the method used is "right". + now += timedelta(seconds=3600) + with patch("homeassistant.util.dt.utcnow", return_value=now): + hass.states.async_set(entity_id, 0, {"device_class": None}) + await hass.async_block_till_done() await hass.async_block_till_done() state = hass.states.get("sensor.integration") assert state is not None - assert state.state == STATE_UNKNOWN + assert state.state == STATE_UNKNOWN if method != "right" else "0.000" # With the source sensor updated successfully, the Reimann sensor # should have a zero (known) value. - hass.states.async_set(entity_id, 1, {"device_class": None}) + now += timedelta(seconds=3600) + with patch("homeassistant.util.dt.utcnow", return_value=now): + hass.states.async_set(entity_id, 1, {"device_class": None}) + await hass.async_block_till_done() await hass.async_block_till_done() state = hass.states.get("sensor.integration") assert state is not None - assert round(float(state.state)) == 0 + assert round(float(state.state)) == 0 if method != "right" else 1 From 86ad5ad1137783c9b935296ddfbac773db8ab74b Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Tue, 23 May 2023 11:21:50 +0200 Subject: [PATCH 458/822] Change snapcast to local_push (#93113) --- homeassistant/components/snapcast/manifest.json | 2 +- homeassistant/generated/integrations.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/snapcast/manifest.json b/homeassistant/components/snapcast/manifest.json index 13091689fed8..f59283bb5f61 100644 --- a/homeassistant/components/snapcast/manifest.json +++ b/homeassistant/components/snapcast/manifest.json @@ -4,7 +4,7 @@ "codeowners": ["@luar123"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/snapcast", - "iot_class": "local_polling", + "iot_class": "local_push", "loggers": ["construct", "snapcast"], "requirements": ["snapcast==2.3.3"] } diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index c69ac70e7d67..c9cd79e60dc3 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5097,7 +5097,7 @@ "name": "Snapcast", "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling" + "iot_class": "local_push" }, "snips": { "name": "Snips", From 7e1a946705e79c9e3c3585b5fb0643d549592d4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Tue, 23 May 2023 11:23:32 +0200 Subject: [PATCH 459/822] Support Airzone temperature ranges (HEAT_COOL) (#93110) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * airzone: climate: add Temperature range support This is useful for HEAT_COOL climate mode (Airzone AUTO). Signed-off-by: Álvaro Fernández Rojas * trigger CI --------- Signed-off-by: Álvaro Fernández Rojas --- homeassistant/components/airzone/climate.py | 27 ++++++++++++-- tests/components/airzone/test_climate.py | 40 +++++++++++++++++++++ 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airzone/climate.py b/homeassistant/components/airzone/climate.py index 764beddd4336..fda007cdc823 100644 --- a/homeassistant/components/airzone/climate.py +++ b/homeassistant/components/airzone/climate.py @@ -5,11 +5,16 @@ from typing import Any, Final from aioairzone.common import OperationAction, OperationMode from aioairzone.const import ( + API_COOL_SET_POINT, + API_HEAT_SET_POINT, API_MODE, API_ON, API_SET_POINT, API_SPEED, AZD_ACTION, + AZD_COOL_TEMP_SET, + AZD_DOUBLE_SET_POINT, + AZD_HEAT_TEMP_SET, AZD_HUMIDITY, AZD_MASTER, AZD_MODE, @@ -27,6 +32,8 @@ from aioairzone.const import ( ) from homeassistant.components.climate import ( + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, FAN_AUTO, FAN_HIGH, FAN_LOW, @@ -137,6 +144,10 @@ class AirzoneClimate(AirzoneZoneEntity, ClimateEntity): and self.get_airzone_value(AZD_SPEEDS) is not None ): self._set_fan_speeds() + if self.get_airzone_value(AZD_DOUBLE_SET_POINT): + self._attr_supported_features |= ( + ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + ) self._async_update_attrs() @@ -201,9 +212,12 @@ class AirzoneClimate(AirzoneZoneEntity, ClimateEntity): async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" - params = { - API_SET_POINT: kwargs.get(ATTR_TEMPERATURE), - } + params = {} + if ATTR_TEMPERATURE in kwargs: + params[API_SET_POINT] = kwargs[ATTR_TEMPERATURE] + if ATTR_TARGET_TEMP_LOW in kwargs and ATTR_TARGET_TEMP_HIGH in kwargs: + params[API_COOL_SET_POINT] = kwargs[ATTR_TARGET_TEMP_LOW] + params[API_HEAT_SET_POINT] = kwargs[ATTR_TARGET_TEMP_HIGH] await self._async_update_hvac_params(params) @callback @@ -229,3 +243,10 @@ class AirzoneClimate(AirzoneZoneEntity, ClimateEntity): self._attr_target_temperature = self.get_airzone_value(AZD_TEMP_SET) if self.supported_features & ClimateEntityFeature.FAN_MODE: self._attr_fan_mode = self._speeds.get(self.get_airzone_value(AZD_SPEED)) + if self.supported_features & ClimateEntityFeature.TARGET_TEMPERATURE_RANGE: + self._attr_target_temperature_high = self.get_airzone_value( + AZD_HEAT_TEMP_SET + ) + self._attr_target_temperature_low = self.get_airzone_value( + AZD_COOL_TEMP_SET + ) diff --git a/tests/components/airzone/test_climate.py b/tests/components/airzone/test_climate.py index cfbf6049e7c6..0baac5c9d71a 100644 --- a/tests/components/airzone/test_climate.py +++ b/tests/components/airzone/test_climate.py @@ -3,7 +3,9 @@ from unittest.mock import patch from aioairzone.common import OperationMode from aioairzone.const import ( + API_COOL_SET_POINT, API_DATA, + API_HEAT_SET_POINT, API_MODE, API_ON, API_SET_POINT, @@ -25,6 +27,8 @@ from homeassistant.components.climate import ( ATTR_HVAC_MODES, ATTR_MAX_TEMP, ATTR_MIN_TEMP, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, ATTR_TARGET_TEMP_STEP, DOMAIN as CLIMATE_DOMAIN, FAN_AUTO, @@ -494,3 +498,39 @@ async def test_airzone_climate_set_temp_error(hass: HomeAssistant) -> None: state = hass.states.get("climate.dorm_2") assert state.attributes.get(ATTR_TEMPERATURE) == 19.5 + + +async def test_airzone_climate_set_temp_range(hass: HomeAssistant) -> None: + """Test setting the target temperature range.""" + + HVAC_MOCK = { + API_DATA: [ + { + API_SYSTEM_ID: 3, + API_ZONE_ID: 1, + API_COOL_SET_POINT: 68.0, + API_HEAT_SET_POINT: 77.0, + } + ] + } + + await async_init_integration(hass) + + with patch( + "homeassistant.components.airzone.AirzoneLocalApi.put_hvac", + return_value=HVAC_MOCK, + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: "climate.dkn_plus", + ATTR_TARGET_TEMP_HIGH: 25.0, + ATTR_TARGET_TEMP_LOW: 20.0, + }, + blocking=True, + ) + + state = hass.states.get("climate.dkn_plus") + assert state.attributes.get(ATTR_TARGET_TEMP_HIGH) == 25.0 + assert state.attributes.get(ATTR_TARGET_TEMP_LOW) == 20.0 From d0d74eb30edc38501d34f850414bbc38e79bc120 Mon Sep 17 00:00:00 2001 From: Paul Frank Date: Tue, 23 May 2023 11:28:33 +0200 Subject: [PATCH 460/822] Bump mystrom to 2.2.0 (#93389) --- homeassistant/components/mystrom/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mystrom/manifest.json b/homeassistant/components/mystrom/manifest.json index d6f551451d47..7659b1d8025e 100644 --- a/homeassistant/components/mystrom/manifest.json +++ b/homeassistant/components/mystrom/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/mystrom", "iot_class": "local_polling", "loggers": ["pymystrom"], - "requirements": ["python-mystrom==1.1.2"] + "requirements": ["python-mystrom==2.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 785398bdf48c..414aff976ac9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2095,7 +2095,7 @@ python-miio==0.5.12 python-mpd2==3.0.5 # homeassistant.components.mystrom -python-mystrom==1.1.2 +python-mystrom==2.2.0 # homeassistant.components.nest python-nest==4.2.0 From 1dccb8a9a9486b372a7f576f66c5081064cd2747 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 23 May 2023 11:33:51 +0200 Subject: [PATCH 461/822] Cleanup deprecated YAML from samsungtv (#93392) --- .../components/samsungtv/__init__.py | 75 +-- .../components/samsungtv/config_flow.py | 23 - homeassistant/components/samsungtv/const.py | 2 - .../components/samsungtv/media_player.py | 24 +- tests/components/samsungtv/__init__.py | 3 +- tests/components/samsungtv/conftest.py | 9 - .../samsungtv/snapshots/test_init.ambr | 10 - .../components/samsungtv/test_config_flow.py | 490 +++++------------- tests/components/samsungtv/test_init.py | 86 +-- .../components/samsungtv/test_media_player.py | 214 +++----- 10 files changed, 226 insertions(+), 710 deletions(-) delete mode 100644 tests/components/samsungtv/snapshots/test_init.ambr diff --git a/homeassistant/components/samsungtv/__init__.py b/homeassistant/components/samsungtv/__init__.py index 55d0fbdfbdb9..a5deb3ca629e 100644 --- a/homeassistant/components/samsungtv/__init__.py +++ b/homeassistant/components/samsungtv/__init__.py @@ -3,14 +3,11 @@ from __future__ import annotations from collections.abc import Coroutine, Mapping from functools import partial -import socket from typing import Any from urllib.parse import urlparse import getmac -import voluptuous as vol -from homeassistant import config_entries from homeassistant.components import ssdp from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -18,7 +15,6 @@ from homeassistant.const import ( CONF_MAC, CONF_METHOD, CONF_MODEL, - CONF_NAME, CONF_PORT, CONF_TOKEN, EVENT_HOMEASSISTANT_STOP, @@ -30,10 +26,8 @@ from homeassistant.helpers import ( config_validation as cv, device_registry as dr, entity_registry as er, - issue_registry as ir, ) from homeassistant.helpers.debounce import Debouncer -from homeassistant.helpers.typing import ConfigType from .bridge import ( SamsungTVBridge, @@ -42,11 +36,9 @@ from .bridge import ( model_requires_encryption, ) from .const import ( - CONF_ON_ACTION, CONF_SESSION_ID, CONF_SSDP_MAIN_TV_AGENT_LOCATION, CONF_SSDP_RENDERING_CONTROL_LOCATION, - DEFAULT_NAME, DOMAIN, ENTRY_RELOAD_COOLDOWN, LEGACY_PORT, @@ -57,73 +49,9 @@ from .const import ( UPNP_SVC_RENDERING_CONTROL, ) - -def ensure_unique_hosts(value: dict[Any, Any]) -> dict[Any, Any]: - """Validate that all configs have a unique host.""" - vol.Schema(vol.Unique("duplicate host entries found"))( - [entry[CONF_HOST] for entry in value] - ) - return value - - PLATFORMS = [Platform.MEDIA_PLAYER] -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.All( - cv.ensure_list, - [ - cv.deprecated(CONF_PORT), - vol.Schema( - { - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_PORT): cv.port, - vol.Optional(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, - } - ), - ], - ensure_unique_hosts, - ) - }, - extra=vol.ALLOW_EXTRA, -) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Samsung TV integration.""" - hass.data[DOMAIN] = {} - if DOMAIN not in config: - return True - - ir.async_create_issue( - hass, - DOMAIN, - "deprecated_yaml", - breaks_in_ha_version="2023.6.0", - is_fixable=False, - severity=ir.IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "on_action_url": "https://www.home-assistant.io/integrations/samsungtv/#turn-on-action" - }, - learn_more_url="https://www.home-assistant.io/integrations/samsungtv/#turn-on-action", - ) - for entry_config in config[DOMAIN]: - ip_address = await hass.async_add_executor_job( - socket.gethostbyname, entry_config[CONF_HOST] - ) - hass.data[DOMAIN][ip_address] = { - CONF_ON_ACTION: entry_config.get(CONF_ON_ACTION) - } - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=entry_config, - ) - ) - return True +CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) @callback @@ -195,6 +123,7 @@ async def _async_update_ssdp_locations(hass: HomeAssistant, entry: ConfigEntry) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the Samsung TV platform.""" + hass.data.setdefault(DOMAIN, {}) # Initialize bridge if entry.data.get(CONF_METHOD) == METHOD_ENCRYPTED_WEBSOCKET: diff --git a/homeassistant/components/samsungtv/config_flow.py b/homeassistant/components/samsungtv/config_flow.py index bc8cc895849c..f98e3667b592 100644 --- a/homeassistant/components/samsungtv/config_flow.py +++ b/homeassistant/components/samsungtv/config_flow.py @@ -35,12 +35,9 @@ from .const import ( CONF_SSDP_RENDERING_CONTROL_LOCATION, DEFAULT_MANUFACTURER, DOMAIN, - ENCRYPTED_WEBSOCKET_PORT, - LEGACY_PORT, LOGGER, METHOD_ENCRYPTED_WEBSOCKET, METHOD_LEGACY, - METHOD_WEBSOCKET, RESULT_AUTH_MISSING, RESULT_CANNOT_CONNECT, RESULT_INVALID_PIN, @@ -50,7 +47,6 @@ from .const import ( SUCCESSFUL_RESULTS, UPNP_SVC_MAIN_TV_AGENT, UPNP_SVC_RENDERING_CONTROL, - WEBSOCKET_PORTS, ) DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str, vol.Required(CONF_NAME): str}) @@ -231,25 +227,6 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._mac = mac return True - async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: - """Handle configuration by yaml file.""" - # We need to import even if we cannot validate - # since the TV may be off at startup - await self._async_set_name_host_from_input(user_input) - self._async_abort_entries_match({CONF_HOST: self._host}) - port = user_input.get(CONF_PORT) - if port in WEBSOCKET_PORTS: - user_input[CONF_METHOD] = METHOD_WEBSOCKET - elif port == ENCRYPTED_WEBSOCKET_PORT: - user_input[CONF_METHOD] = METHOD_ENCRYPTED_WEBSOCKET - elif port == LEGACY_PORT: - user_input[CONF_METHOD] = METHOD_LEGACY - user_input[CONF_MANUFACTURER] = DEFAULT_MANUFACTURER - return self.async_create_entry( - title=self._title, - data=user_input, - ) - async def _async_set_name_host_from_input(self, user_input: dict[str, Any]) -> None: try: self._host = await self.hass.async_add_executor_job( diff --git a/homeassistant/components/samsungtv/const.py b/homeassistant/components/samsungtv/const.py index 2585d742be00..6699d26243bb 100644 --- a/homeassistant/components/samsungtv/const.py +++ b/homeassistant/components/samsungtv/const.py @@ -6,7 +6,6 @@ DOMAIN = "samsungtv" ATTR_PROPERTIES = "properties" -DEFAULT_NAME = "Samsung TV" DEFAULT_MANUFACTURER = "Samsung" VALUE_CONF_NAME = "HomeAssistant" @@ -16,7 +15,6 @@ CONF_DESCRIPTION = "description" CONF_MANUFACTURER = "manufacturer" CONF_SSDP_RENDERING_CONTROL_LOCATION = "ssdp_rendering_control_location" CONF_SSDP_MAIN_TV_AGENT_LOCATION = "ssdp_main_tv_agent_location" -CONF_ON_ACTION = "turn_on_action" CONF_SESSION_ID = "session_id" RESULT_AUTH_MISSING = "auth_missing" diff --git a/homeassistant/components/samsungtv/media_player.py b/homeassistant/components/samsungtv/media_player.py index 302d9c4915df..d4c04942e63c 100644 --- a/homeassistant/components/samsungtv/media_player.py +++ b/homeassistant/components/samsungtv/media_player.py @@ -41,16 +41,13 @@ from homeassistant.helpers import ( from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.script import Script from homeassistant.helpers.trigger import PluggableAction from homeassistant.util import dt as dt_util from .bridge import SamsungTVBridge, SamsungTVWSBridge from .const import ( CONF_MANUFACTURER, - CONF_ON_ACTION, CONF_SSDP_RENDERING_CONTROL_LOCATION, - DEFAULT_NAME, DOMAIN, LOGGER, ) @@ -86,16 +83,7 @@ async def async_setup_entry( ) -> None: """Set up the Samsung TV from a config entry.""" bridge = hass.data[DOMAIN][entry.entry_id] - - host = entry.data[CONF_HOST] - on_script = None - data = hass.data[DOMAIN] - if turn_on_action := data.get(host, {}).get(CONF_ON_ACTION): - on_script = Script( - hass, turn_on_action, entry.data.get(CONF_NAME, DEFAULT_NAME), DOMAIN - ) - - async_add_entities([SamsungTVDevice(bridge, entry, on_script)], True) + async_add_entities([SamsungTVDevice(bridge, entry)], True) class SamsungTVDevice(MediaPlayerEntity): @@ -107,7 +95,6 @@ class SamsungTVDevice(MediaPlayerEntity): self, bridge: SamsungTVBridge, config_entry: ConfigEntry, - on_script: Script | None, ) -> None: """Initialize the Samsung device.""" self._config_entry = config_entry @@ -117,7 +104,6 @@ class SamsungTVDevice(MediaPlayerEntity): CONF_SSDP_RENDERING_CONTROL_LOCATION ) self._turn_on = PluggableAction(self.async_write_ha_state) - self._on_script = on_script # Assume that the TV is in Play mode self._playing: bool = True @@ -130,8 +116,8 @@ class SamsungTVDevice(MediaPlayerEntity): self._app_list_event: asyncio.Event = asyncio.Event() self._attr_supported_features = SUPPORT_SAMSUNGTV - if self._on_script or self._mac: - # (deprecated) add turn-on if on_script YAML or mac is available + if self._mac: + # (deprecated) add turn-on if mac is available # Triggers have not yet been registered so this is adjusted in the property self._attr_supported_features |= MediaPlayerEntityFeature.TURN_ON if self._ssdp_rendering_control_location: @@ -375,7 +361,6 @@ class SamsungTVDevice(MediaPlayerEntity): return ( self.state == MediaPlayerState.ON or bool(self._turn_on) - or self._on_script is not None or self._mac is not None or self._power_off_in_progress() ) @@ -477,9 +462,6 @@ class SamsungTVDevice(MediaPlayerEntity): """Turn the media player on.""" if self._turn_on: await self._turn_on.async_run(self.hass, self._context) - elif self._on_script: - # YAML on_script is deprecated - replaced by turn_on trigger - await self._on_script.async_run(context=self._context) elif self._mac: await self.hass.async_add_executor_job(self._wake_on_lan) diff --git a/tests/components/samsungtv/__init__.py b/tests/components/samsungtv/__init__.py index 53e47f6170b2..be28d6132abb 100644 --- a/tests/components/samsungtv/__init__.py +++ b/tests/components/samsungtv/__init__.py @@ -7,7 +7,6 @@ from homeassistant.components.samsungtv.const import DOMAIN, ENTRY_RELOAD_COOLDO from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType -from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed @@ -29,7 +28,7 @@ async def setup_samsungtv_entry(hass: HomeAssistant, data: ConfigType) -> Config ) entry.add_to_hass(hass) - await async_setup_component(hass, DOMAIN, {}) + await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() return entry diff --git a/tests/components/samsungtv/conftest.py b/tests/components/samsungtv/conftest.py index 163805746425..5e8ab9311aa3 100644 --- a/tests/components/samsungtv/conftest.py +++ b/tests/components/samsungtv/conftest.py @@ -290,15 +290,6 @@ def remoteencws_fixture() -> Mock: yield remoteencws -@pytest.fixture(name="delay") -def delay_fixture() -> Mock: - """Patch the delay script function.""" - with patch( - "homeassistant.components.samsungtv.media_player.Script.async_run" - ) as delay: - yield delay - - @pytest.fixture def mock_now() -> datetime: """Fixture for dtutil.now.""" diff --git a/tests/components/samsungtv/snapshots/test_init.ambr b/tests/components/samsungtv/snapshots/test_init.ambr deleted file mode 100644 index 877bfe04205c..000000000000 --- a/tests/components/samsungtv/snapshots/test_init.ambr +++ /dev/null @@ -1,10 +0,0 @@ -# serializer version: 1 -# name: test_setup - IssueRegistryItemSnapshot({ - 'created': , - 'dismissed_version': None, - 'domain': 'samsungtv', - 'is_persistent': False, - 'issue_id': 'deprecated_yaml', - }) -# --- diff --git a/tests/components/samsungtv/test_config_flow.py b/tests/components/samsungtv/test_config_flow.py index ac0072c88ce3..3c4b982b000a 100644 --- a/tests/components/samsungtv/test_config_flow.py +++ b/tests/components/samsungtv/test_config_flow.py @@ -1,5 +1,4 @@ """Tests for Samsung TV config flow.""" -import socket from unittest.mock import ANY, AsyncMock, Mock, call, patch import pytest @@ -28,13 +27,9 @@ from homeassistant.components.samsungtv.const import ( DEFAULT_MANUFACTURER, DOMAIN, LEGACY_PORT, - METHOD_ENCRYPTED_WEBSOCKET, - METHOD_LEGACY, - METHOD_WEBSOCKET, RESULT_AUTH_MISSING, RESULT_CANNOT_CONNECT, RESULT_NOT_SUPPORTED, - RESULT_UNKNOWN_HOST, TIMEOUT_REQUEST, TIMEOUT_WEBSOCKET, ) @@ -61,7 +56,6 @@ from homeassistant.data_entry_flow import FlowResultType from homeassistant.setup import async_setup_component from .const import ( - MOCK_CONFIG_ENCRYPTED_WS, MOCK_ENTRYDATA_ENCRYPTED_WS, MOCK_ENTRYDATA_WS, MOCK_SSDP_DATA_MAIN_TV_AGENT_ST, @@ -907,136 +901,6 @@ async def test_ssdp_already_configured(hass: HomeAssistant) -> None: assert entry.unique_id == "123" -@pytest.mark.usefixtures("remote") -async def test_import_legacy(hass: HomeAssistant) -> None: - """Test importing from yaml with hostname.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=MOCK_IMPORT_DATA, - ) - await hass.async_block_till_done() - assert result["type"] == "create_entry" - assert result["title"] == "fake" - assert result["data"][CONF_HOST] == "fake_host" - assert result["data"][CONF_NAME] == "fake" - assert result["data"][CONF_MANUFACTURER] == "Samsung" - assert result["result"].unique_id is None - - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - assert entries[0].data[CONF_METHOD] == METHOD_LEGACY - assert entries[0].data[CONF_PORT] == LEGACY_PORT - - -@pytest.mark.usefixtures("remote", "remotews", "rest_api_failing") -async def test_import_legacy_without_name( - hass: HomeAssistant, mock_setup_entry: AsyncMock -) -> None: - """Test importing from yaml without a name.""" - with patch( - "homeassistant.components.samsungtv.bridge.SamsungTVEncryptedWSAsyncRemote.start_listening", - side_effect=WebSocketProtocolError("Boom"), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=MOCK_IMPORT_DATA_WITHOUT_NAME, - ) - await hass.async_block_till_done() - assert result["type"] == "create_entry" - assert result["title"] == "fake_host" - assert result["data"][CONF_HOST] == "fake_host" - assert result["data"][CONF_MANUFACTURER] == "Samsung" - assert result["result"].unique_id is None - - mock_setup_entry.assert_called_once() - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - # METHOD / PORT failed during import - # They will get checked/set on setup - assert CONF_METHOD not in entries[0].data - assert CONF_PORT not in entries[0].data - - -@pytest.mark.usefixtures("remotews", "rest_api") -async def test_import_websocket(hass: HomeAssistant) -> None: - """Test importing from yaml with hostname.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=MOCK_IMPORT_WSDATA, - ) - await hass.async_block_till_done() - assert result["type"] == "create_entry" - assert result["title"] == "fake" - assert result["data"][CONF_METHOD] == METHOD_WEBSOCKET - assert result["data"][CONF_PORT] == 8002 - assert result["data"][CONF_HOST] == "fake_host" - assert result["data"][CONF_NAME] == "fake" - assert result["data"][CONF_MANUFACTURER] == "Samsung" - assert result["result"].unique_id is None - - -@pytest.mark.usefixtures("remoteencws") -async def test_import_websocket_encrypted(hass: HomeAssistant) -> None: - """Test importing from yaml with hostname.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=MOCK_CONFIG_ENCRYPTED_WS, - ) - await hass.async_block_till_done() - - assert result["type"] == "create_entry" - assert result["title"] == "fake" - assert result["data"][CONF_METHOD] == METHOD_ENCRYPTED_WEBSOCKET - assert result["data"][CONF_PORT] == 8000 - assert result["data"][CONF_HOST] == "fake_host" - assert result["data"][CONF_NAME] == "fake" - assert result["data"][CONF_MANUFACTURER] == "Samsung" - assert result["result"].unique_id is None - - -@pytest.mark.usefixtures("remotews", "rest_api") -async def test_import_websocket_without_port(hass: HomeAssistant) -> None: - """Test importing from yaml with hostname by no port.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=MOCK_IMPORT_WSDATA, - ) - await hass.async_block_till_done() - assert result["type"] == "create_entry" - assert result["title"] == "fake" - assert result["data"][CONF_HOST] == "fake_host" - assert result["data"][CONF_NAME] == "fake" - assert result["data"][CONF_MANUFACTURER] == "Samsung" - assert result["result"].unique_id is None - - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - assert entries[0].data[CONF_METHOD] == METHOD_WEBSOCKET - assert entries[0].data[CONF_PORT] == 8002 - - -@pytest.mark.usefixtures("remotews") -async def test_import_unknown_host(hass: HomeAssistant) -> None: - """Test importing from yaml with hostname that does not resolve.""" - with patch( - "homeassistant.components.samsungtv.config_flow.socket.gethostbyname", - side_effect=socket.gaierror, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=MOCK_IMPORT_DATA, - ) - await hass.async_block_till_done() - assert result["type"] == "abort" - assert result["reason"] == RESULT_UNKNOWN_HOST - - @pytest.mark.usefixtures("remotews", "rest_api_non_ssl_only", "remoteencws_failing") async def test_dhcp_wireless(hass: HomeAssistant) -> None: """Test starting a flow from dhcp.""" @@ -1389,18 +1253,14 @@ async def test_update_missing_mac_unique_id_added_from_dhcp( """Test missing mac and unique id added.""" entry = MockConfigEntry(domain=DOMAIN, data=MOCK_OLD_ENTRY, unique_id=None) entry.add_to_hass(hass) - with patch( - "homeassistant.components.samsungtv.async_setup", - return_value=True, - ) as mock_setup: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_DHCP}, - data=MOCK_DHCP_DATA, - ) - await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=MOCK_DHCP_DATA, + ) + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 assert result["type"] == "abort" assert result["reason"] == "already_configured" @@ -1415,18 +1275,15 @@ async def test_update_missing_mac_unique_id_added_from_zeroconf( """Test missing mac and unique id added.""" entry = MockConfigEntry(domain=DOMAIN, data=MOCK_OLD_ENTRY, unique_id=None) entry.add_to_hass(hass) - with patch( - "homeassistant.components.samsungtv.async_setup", - return_value=True, - ) as mock_setup: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_ZEROCONF}, - data=MOCK_ZEROCONF_DATA, - ) - await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=MOCK_ZEROCONF_DATA, + ) + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 + assert result["type"] == "abort" assert result["reason"] == "already_configured" assert entry.data[CONF_MAC] == "aa:bb:zz:ee:rr:oo" @@ -1444,18 +1301,14 @@ async def test_update_missing_model_added_from_ssdp( unique_id=None, ) entry.add_to_hass(hass) - with patch( - "homeassistant.components.samsungtv.async_setup", - return_value=True, - ) as mock_setup: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=MOCK_SSDP_DATA, - ) - await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=MOCK_SSDP_DATA, + ) + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 assert result["type"] == "abort" assert result["reason"] == "already_configured" @@ -1469,18 +1322,14 @@ async def test_update_missing_mac_unique_id_ssdp_location_added_from_ssdp( """Test missing mac, ssdp_location, and unique id added via ssdp.""" entry = MockConfigEntry(domain=DOMAIN, data=MOCK_OLD_ENTRY, unique_id=None) entry.add_to_hass(hass) - with patch( - "homeassistant.components.samsungtv.async_setup", - return_value=True, - ) as mock_setup: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=MOCK_SSDP_DATA, - ) - await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=MOCK_SSDP_DATA, + ) + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 assert result["type"] == "abort" assert result["reason"] == "already_configured" @@ -1529,18 +1378,14 @@ async def test_update_missing_mac_unique_id_added_ssdp_location_updated_from_ssd unique_id=None, ) entry.add_to_hass(hass) - with patch( - "homeassistant.components.samsungtv.async_setup", - return_value=True, - ) as mock_setup: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=MOCK_SSDP_DATA, - ) - await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=MOCK_SSDP_DATA, + ) + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 assert result["type"] == "abort" assert result["reason"] == "already_configured" @@ -1566,18 +1411,14 @@ async def test_update_missing_mac_unique_id_added_ssdp_location_rendering_st_upd unique_id=None, ) entry.add_to_hass(hass) - with patch( - "homeassistant.components.samsungtv.async_setup", - return_value=True, - ) as mock_setup: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=MOCK_SSDP_DATA_RENDERING_CONTROL_ST, - ) - await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=MOCK_SSDP_DATA_RENDERING_CONTROL_ST, + ) + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 assert result["type"] == "abort" assert result["reason"] == "already_configured" @@ -1605,18 +1446,14 @@ async def test_update_missing_mac_unique_id_added_ssdp_location_main_tv_agent_st unique_id=None, ) entry.add_to_hass(hass) - with patch( - "homeassistant.components.samsungtv.async_setup", - return_value=True, - ) as mock_setup: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=MOCK_SSDP_DATA_MAIN_TV_AGENT_ST, - ) - await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=MOCK_SSDP_DATA_MAIN_TV_AGENT_ST, + ) + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 assert result["type"] == "abort" assert result["reason"] == "already_configured" @@ -1644,18 +1481,14 @@ async def test_update_ssdp_location_rendering_st_updated_from_ssdp( unique_id="be9554b9-c9fb-41f4-8920-22da015376a4", ) entry.add_to_hass(hass) - with patch( - "homeassistant.components.samsungtv.async_setup", - return_value=True, - ) as mock_setup: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=MOCK_SSDP_DATA_RENDERING_CONTROL_ST, - ) - await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=MOCK_SSDP_DATA_RENDERING_CONTROL_ST, + ) + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 assert result["type"] == "abort" assert result["reason"] == "already_configured" @@ -1679,18 +1512,14 @@ async def test_update_main_tv_ssdp_location_rendering_st_updated_from_ssdp( unique_id="be9554b9-c9fb-41f4-8920-22da015376a4", ) entry.add_to_hass(hass) - with patch( - "homeassistant.components.samsungtv.async_setup", - return_value=True, - ) as mock_setup: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=MOCK_SSDP_DATA_MAIN_TV_AGENT_ST, - ) - await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=MOCK_SSDP_DATA_MAIN_TV_AGENT_ST, + ) + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 assert result["type"] == "abort" assert result["reason"] == "already_configured" @@ -1714,18 +1543,15 @@ async def test_update_missing_mac_added_unique_id_preserved_from_zeroconf( unique_id="0d1cef00-00dc-1000-9c80-4844f7b172de", ) entry.add_to_hass(hass) - with patch( - "homeassistant.components.samsungtv.async_setup", - return_value=True, - ) as mock_setup: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_ZEROCONF}, - data=MOCK_ZEROCONF_DATA, - ) - await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=MOCK_ZEROCONF_DATA, + ) + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 + assert result["type"] == "abort" assert result["reason"] == "already_configured" assert entry.data[CONF_MAC] == "aa:bb:zz:ee:rr:oo" @@ -1743,20 +1569,17 @@ async def test_update_legacy_missing_mac_from_dhcp( unique_id="0d1cef00-00dc-1000-9c80-4844f7b172de", ) entry.add_to_hass(hass) - with patch( - "homeassistant.components.samsungtv.async_setup", - return_value=True, - ) as mock_setup: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( - ip=EXISTING_IP, macaddress="aa:bb:cc:dd:ee:ff", hostname="fake_hostname" - ), - ) - await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=dhcp.DhcpServiceInfo( + ip=EXISTING_IP, macaddress="aa:bb:cc:dd:ee:ff", hostname="fake_hostname" + ), + ) + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 + assert result["type"] == "abort" assert result["reason"] == "already_configured" assert entry.data[CONF_MAC] == "aa:bb:cc:dd:ee:ff" @@ -1780,10 +1603,7 @@ async def test_update_legacy_missing_mac_from_dhcp_no_unique_id( ), patch( "homeassistant.components.samsungtv.bridge.SamsungTVEncryptedWSAsyncRemote.start_listening", side_effect=WebSocketProtocolError("Boom"), - ), patch( - "homeassistant.components.samsungtv.async_setup", - return_value=True, - ) as mock_setup: + ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, @@ -1792,8 +1612,8 @@ async def test_update_legacy_missing_mac_from_dhcp_no_unique_id( ), ) await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 + assert result["type"] == "abort" assert result["reason"] == "not_supported" assert entry.data[CONF_MAC] == "aa:bb:cc:dd:ee:ff" @@ -1811,18 +1631,14 @@ async def test_update_ssdp_location_unique_id_added_from_ssdp( unique_id=None, ) entry.add_to_hass(hass) - with patch( - "homeassistant.components.samsungtv.async_setup", - return_value=True, - ) as mock_setup: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=MOCK_SSDP_DATA, - ) - await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=MOCK_SSDP_DATA, + ) + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 assert result["type"] == "abort" assert result["reason"] == "already_configured" @@ -1843,18 +1659,14 @@ async def test_update_ssdp_location_unique_id_added_from_ssdp_with_rendering_con unique_id=None, ) entry.add_to_hass(hass) - with patch( - "homeassistant.components.samsungtv.async_setup", - return_value=True, - ) as mock_setup: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=MOCK_SSDP_DATA_RENDERING_CONTROL_ST, - ) - await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=MOCK_SSDP_DATA_RENDERING_CONTROL_ST, + ) + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 assert result["type"] == "abort" assert result["reason"] == "already_configured" @@ -2060,18 +1872,14 @@ async def test_update_incorrect_udn_matching_upnp_udn_unique_id_added_from_ssdp( unique_id="0d1cef00-00dc-1000-9c80-4844f7b172de", ) entry.add_to_hass(hass) - with patch( - "homeassistant.components.samsungtv.async_setup", - return_value=True, - ) as mock_setup: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=MOCK_SSDP_DATA, - ) - await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=MOCK_SSDP_DATA, + ) + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 assert result["type"] == "abort" assert result["reason"] == "already_configured" @@ -2090,18 +1898,14 @@ async def test_update_incorrect_udn_matching_mac_unique_id_added_from_ssdp( unique_id=None, ) entry.add_to_hass(hass) - with patch( - "homeassistant.components.samsungtv.async_setup", - return_value=True, - ) as mock_setup: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=MOCK_SSDP_DATA, - ) - await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=MOCK_SSDP_DATA, + ) + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 assert result["type"] == "abort" assert result["reason"] == "already_configured" @@ -2121,18 +1925,14 @@ async def test_update_incorrect_udn_matching_mac_from_dhcp( unique_id="0d1cef00-00dc-1000-9c80-4844f7b172de", ) entry.add_to_hass(hass) - with patch( - "homeassistant.components.samsungtv.async_setup", - return_value=True, - ) as mock_setup: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_DHCP}, - data=MOCK_DHCP_DATA, - ) - await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=MOCK_DHCP_DATA, + ) + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 assert result["type"] == "abort" assert result["reason"] == "already_configured" @@ -2152,18 +1952,14 @@ async def test_no_update_incorrect_udn_not_matching_mac_from_dhcp( unique_id="0d1cef00-00dc-1000-9c80-4844f7b172de", ) entry.add_to_hass(hass) - with patch( - "homeassistant.components.samsungtv.async_setup", - return_value=True, - ) as mock_setup: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_DHCP}, - data=MOCK_DHCP_DATA, - ) - await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 0 - assert len(mock_setup_entry.mock_calls) == 0 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=MOCK_DHCP_DATA, + ) + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 0 assert result["type"] == "form" assert result["step_id"] == "confirm" diff --git a/tests/components/samsungtv/test_init.py b/tests/components/samsungtv/test_init.py index 24b3e7d4c7ea..e15d84913be5 100644 --- a/tests/components/samsungtv/test_init.py +++ b/tests/components/samsungtv/test_init.py @@ -2,12 +2,10 @@ 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 ( CONF_MANUFACTURER, - CONF_ON_ACTION, CONF_SESSION_ID, CONF_SSDP_MAIN_TV_AGENT_LOCATION, CONF_SSDP_RENDERING_CONTROL_LOCATION, @@ -32,8 +30,6 @@ from homeassistant.const import ( SERVICE_VOLUME_UP, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir -from homeassistant.setup import async_setup_component from . import setup_samsungtv_entry from .const import ( @@ -48,43 +44,16 @@ from tests.common import MockConfigEntry ENTITY_ID = f"{DOMAIN}.fake_name" MOCK_CONFIG = { - SAMSUNGTV_DOMAIN: [ - { - CONF_HOST: "fake_host", - CONF_NAME: "fake_name", - CONF_ON_ACTION: [{"delay": "00:00:01"}], - CONF_METHOD: METHOD_WEBSOCKET, - } - ] -} -MOCK_CONFIG_WITHOUT_PORT = { - SAMSUNGTV_DOMAIN: [ - { - CONF_HOST: "fake_host", - CONF_NAME: "fake", - CONF_ON_ACTION: [{"delay": "00:00:01"}], - } - ] -} - -REMOTE_CALL = { - "name": "HomeAssistant", - "description": "HomeAssistant", - "id": "ha.component.samsung", - "host": MOCK_CONFIG[SAMSUNGTV_DOMAIN][0][CONF_HOST], - "method": "legacy", - "port": None, - "timeout": 1, + CONF_HOST: "fake_host", + CONF_NAME: "fake_name", + CONF_METHOD: METHOD_WEBSOCKET, } @pytest.mark.usefixtures("remotews", "remoteencws_failing", "rest_api") -async def test_setup( - hass: HomeAssistant, issue_registry: ir.IssueRegistry, snapshot: SnapshotAssertion -) -> None: +async def test_setup(hass: HomeAssistant) -> None: """Test Samsung TV integration is setup.""" - await async_setup_component(hass, SAMSUNGTV_DOMAIN, MOCK_CONFIG) - await hass.async_block_till_done() + await setup_samsungtv_entry(hass, MOCK_CONFIG) state = hass.states.get(ENTITY_ID) # test name and turn_on @@ -100,12 +69,8 @@ async def test_setup( DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True ) - # ensure deprecated_yaml issue is raised - issue = issue_registry.async_get_issue(SAMSUNGTV_DOMAIN, "deprecated_yaml") - assert issue == snapshot - -async def test_setup_from_yaml_without_port_device_offline(hass: HomeAssistant) -> None: +async def test_setup_without_port_device_offline(hass: HomeAssistant) -> None: """Test import from yaml when the device is offline.""" with patch( "homeassistant.components.samsungtv.bridge.Remote", side_effect=OSError @@ -119,8 +84,7 @@ async def test_setup_from_yaml_without_port_device_offline(hass: HomeAssistant) "homeassistant.components.samsungtv.bridge.SamsungTVWSBridge.async_device_info", return_value=None, ): - await async_setup_component(hass, SAMSUNGTV_DOMAIN, MOCK_CONFIG) - await hass.async_block_till_done() + await setup_samsungtv_entry(hass, MOCK_CONFIG) config_entries_domain = hass.config_entries.async_entries(SAMSUNGTV_DOMAIN) assert len(config_entries_domain) == 1 @@ -128,52 +92,22 @@ async def test_setup_from_yaml_without_port_device_offline(hass: HomeAssistant) @pytest.mark.usefixtures("remotews", "remoteencws_failing", "rest_api") -async def test_setup_from_yaml_without_port_device_online(hass: HomeAssistant) -> None: +async def test_setup_without_port_device_online(hass: HomeAssistant) -> None: """Test import from yaml when the device is online.""" - await async_setup_component(hass, SAMSUNGTV_DOMAIN, MOCK_CONFIG) - await hass.async_block_till_done() + await setup_samsungtv_entry(hass, MOCK_CONFIG) config_entries_domain = hass.config_entries.async_entries(SAMSUNGTV_DOMAIN) assert len(config_entries_domain) == 1 assert config_entries_domain[0].data[CONF_MAC] == "aa:bb:ww:ii:ff:ii" -@pytest.mark.usefixtures("remote") -async def test_setup_duplicate_config( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test duplicate setup of platform.""" - duplicate = { - SAMSUNGTV_DOMAIN: [ - MOCK_CONFIG[SAMSUNGTV_DOMAIN][0], - MOCK_CONFIG[SAMSUNGTV_DOMAIN][0], - ] - } - await async_setup_component(hass, SAMSUNGTV_DOMAIN, duplicate) - await hass.async_block_till_done() - assert hass.states.get(ENTITY_ID) is None - assert len(hass.states.async_all("media_player")) == 0 - assert "duplicate host entries found" in caplog.text - - -@pytest.mark.usefixtures("remotews", "remoteencws_failing", "rest_api") -async def test_setup_duplicate_entries(hass: HomeAssistant) -> None: - """Test duplicate setup of platform.""" - await async_setup_component(hass, SAMSUNGTV_DOMAIN, MOCK_CONFIG) - await hass.async_block_till_done() - assert hass.states.get(ENTITY_ID) - assert len(hass.states.async_all("media_player")) == 1 - await async_setup_component(hass, SAMSUNGTV_DOMAIN, MOCK_CONFIG) - assert len(hass.states.async_all("media_player")) == 1 - - @pytest.mark.usefixtures("remotews", "remoteencws_failing") async def test_setup_h_j_model( hass: HomeAssistant, rest_api: Mock, caplog: pytest.LogCaptureFixture ) -> None: """Test Samsung TV integration is setup.""" rest_api.rest_device_info.return_value = SAMPLE_DEVICE_INFO_UE48JU6400 - await async_setup_component(hass, SAMSUNGTV_DOMAIN, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state diff --git a/tests/components/samsungtv/test_media_player.py b/tests/components/samsungtv/test_media_player.py index b9fa2202afab..1be9982d6c41 100644 --- a/tests/components/samsungtv/test_media_player.py +++ b/tests/components/samsungtv/test_media_player.py @@ -33,15 +33,14 @@ from homeassistant.components.media_player import ( SERVICE_PLAY_MEDIA, SERVICE_SELECT_SOURCE, MediaPlayerDeviceClass, - MediaPlayerEntityFeature, MediaType, ) from homeassistant.components.samsungtv.const import ( - CONF_ON_ACTION, CONF_SSDP_RENDERING_CONTROL_LOCATION, DOMAIN as SAMSUNGTV_DOMAIN, ENCRYPTED_WEBSOCKET_PORT, METHOD_ENCRYPTED_WEBSOCKET, + METHOD_LEGACY, METHOD_WEBSOCKET, TIMEOUT_WEBSOCKET, ) @@ -77,8 +76,6 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.typing import ConfigType -from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from . import async_wait_config_entry_reload, setup_samsungtv_entry @@ -93,25 +90,17 @@ from tests.common import MockConfigEntry, async_fire_time_changed ENTITY_ID = f"{DOMAIN}.fake" MOCK_CONFIG = { - SAMSUNGTV_DOMAIN: [ - { - CONF_HOST: "fake_host", - CONF_NAME: "fake", - CONF_PORT: 55000, - CONF_ON_ACTION: [{"delay": "00:00:01"}], - } - ] + CONF_HOST: "fake_host", + CONF_NAME: "fake", + CONF_PORT: 55000, + CONF_METHOD: METHOD_LEGACY, } MOCK_CONFIGWS = { - SAMSUNGTV_DOMAIN: [ - { - CONF_HOST: "fake_host", - CONF_NAME: "fake", - CONF_PORT: 8001, - CONF_TOKEN: "123456789", - CONF_ON_ACTION: [{"delay": "00:00:01"}], - } - ] + CONF_HOST: "fake_host", + CONF_NAME: "fake", + CONF_PORT: 8001, + CONF_TOKEN: "123456789", + CONF_METHOD: METHOD_WEBSOCKET, } MOCK_CALLS_WS = { CONF_HOST: "fake_host", @@ -143,43 +132,13 @@ MOCK_ENTRY_WS_WITH_MAC = { } -ENTITY_ID_NOTURNON = f"{DOMAIN}.fake_noturnon" -MOCK_CONFIG_NOTURNON = { - SAMSUNGTV_DOMAIN: [ - {CONF_HOST: "fake_noturnon", CONF_NAME: "fake_noturnon", CONF_PORT: 55000} - ] -} - - -@pytest.fixture(name="delay") -def delay_fixture(): - """Patch the delay script function.""" - with patch( - "homeassistant.components.samsungtv.media_player.Script.async_run" - ) as delay: - yield delay - - -async def setup_samsungtv(hass: HomeAssistant, config: ConfigType) -> None: - """Set up mock Samsung TV.""" - await async_setup_component(hass, SAMSUNGTV_DOMAIN, config) - await hass.async_block_till_done() - - @pytest.mark.usefixtures("remote") -async def test_setup_with_turnon(hass: HomeAssistant) -> None: +async def test_setup(hass: HomeAssistant) -> None: """Test setup of platform.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) assert hass.states.get(ENTITY_ID) -@pytest.mark.usefixtures("remote") -async def test_setup_without_turnon(hass: HomeAssistant) -> None: - """Test setup of platform.""" - await setup_samsungtv(hass, MOCK_CONFIG_NOTURNON) - assert hass.states.get(ENTITY_ID_NOTURNON) - - @pytest.mark.usefixtures("remotews", "rest_api") async def test_setup_websocket(hass: HomeAssistant) -> None: """Test setup of platform.""" @@ -192,7 +151,7 @@ async def test_setup_websocket(hass: HomeAssistant) -> None: remote.token = "123456789" remote_class.return_value = remote - await setup_samsungtv(hass, MOCK_CONFIGWS) + await setup_samsungtv_entry(hass, MOCK_CONFIGWS) assert remote_class.call_count == 1 assert remote_class.call_args_list == [call(**MOCK_CALLS_WS)] @@ -229,7 +188,7 @@ async def test_setup_websocket_2(hass: HomeAssistant, mock_now: datetime) -> Non remote.__aexit__ = AsyncMock() remote.token = "987654321" remote_class.return_value = remote - assert await async_setup_component(hass, SAMSUNGTV_DOMAIN, {}) + assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert config_entries[0].data[CONF_MAC] == "aa:bb:ww:ii:ff:ii" @@ -272,7 +231,7 @@ async def test_setup_encrypted_websocket( @pytest.mark.usefixtures("remote") async def test_update_on(hass: HomeAssistant, mock_now: datetime) -> None: """Testing update tv on.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) next_update = mock_now + timedelta(minutes=5) with patch("homeassistant.util.dt.utcnow", return_value=next_update): @@ -286,7 +245,7 @@ async def test_update_on(hass: HomeAssistant, mock_now: datetime) -> None: @pytest.mark.usefixtures("remote") async def test_update_off(hass: HomeAssistant, mock_now: datetime) -> None: """Testing update tv off.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) with patch( "homeassistant.components.samsungtv.bridge.Remote", @@ -298,14 +257,14 @@ async def test_update_off(hass: HomeAssistant, mock_now: datetime) -> None: await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) - assert state.state == STATE_OFF + assert state.state == STATE_UNAVAILABLE async def test_update_off_ws_no_power_state( hass: HomeAssistant, remotews: Mock, rest_api: Mock, mock_now: datetime ) -> None: """Testing update tv off.""" - await setup_samsungtv(hass, MOCK_CONFIGWS) + await setup_samsungtv_entry(hass, MOCK_CONFIGWS) # device_info should only get called once, as part of the setup rest_api.rest_device_info.assert_called_once() rest_api.rest_device_info.reset_mock() @@ -336,13 +295,13 @@ async def test_update_off_ws_with_power_state( ) as mock_device_info, patch.object( remotews, "start_listening", side_effect=WebSocketException("Boom") ) as mock_start_listening: - await setup_samsungtv(hass, MOCK_CONFIGWS) + await setup_samsungtv_entry(hass, MOCK_CONFIGWS) mock_device_info.assert_called_once() mock_start_listening.assert_called_once() state = hass.states.get(ENTITY_ID) - assert state.state == STATE_OFF + assert state.state == STATE_UNAVAILABLE # First update uses start_listening once, and initialises device_info device_info = deepcopy(SAMPLE_DEVICE_INFO_WIFI) @@ -385,7 +344,7 @@ async def test_update_off_ws_with_power_state( rest_api.rest_device_info.assert_called_once() state = hass.states.get(ENTITY_ID) - assert state.state == STATE_OFF + assert state.state == STATE_UNAVAILABLE remotews.start_listening.assert_not_called() @@ -417,7 +376,7 @@ async def test_update_off_encryptedws( @pytest.mark.usefixtures("remote") async def test_update_access_denied(hass: HomeAssistant, mock_now: datetime) -> None: """Testing update tv access denied exception.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) with patch( "homeassistant.components.samsungtv.bridge.Remote", @@ -449,7 +408,7 @@ async def test_update_ws_connection_failure( caplog: pytest.LogCaptureFixture, ) -> None: """Testing update tv connection failure exception.""" - await setup_samsungtv(hass, MOCK_CONFIGWS) + await setup_samsungtv_entry(hass, MOCK_CONFIGWS) with patch.object( remotews, @@ -476,7 +435,7 @@ async def test_update_ws_connection_closed( hass: HomeAssistant, mock_now: datetime, remotews: Mock ) -> None: """Testing update tv connection failure exception.""" - await setup_samsungtv(hass, MOCK_CONFIGWS) + await setup_samsungtv_entry(hass, MOCK_CONFIGWS) with patch.object( remotews, "start_listening", side_effect=ConnectionClosedError(None, None) @@ -495,7 +454,7 @@ async def test_update_ws_unauthorized_error( hass: HomeAssistant, mock_now: datetime, remotews: Mock ) -> None: """Testing update tv unauthorized failure exception.""" - await setup_samsungtv(hass, MOCK_CONFIGWS) + await setup_samsungtv_entry(hass, MOCK_CONFIGWS) with patch.object( remotews, "start_listening", side_effect=UnauthorizedError @@ -519,7 +478,7 @@ async def test_update_unhandled_response( hass: HomeAssistant, mock_now: datetime ) -> None: """Testing update tv unhandled response exception.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) with patch( "homeassistant.components.samsungtv.bridge.Remote", @@ -539,7 +498,7 @@ async def test_connection_closed_during_update_can_recover( hass: HomeAssistant, mock_now: datetime ) -> None: """Testing update tv connection closed exception can recover.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) with patch( "homeassistant.components.samsungtv.bridge.Remote", @@ -551,7 +510,7 @@ async def test_connection_closed_during_update_can_recover( await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) - assert state.state == STATE_OFF + assert state.state == STATE_UNAVAILABLE next_update = mock_now + timedelta(minutes=10) with patch("homeassistant.util.dt.utcnow", return_value=next_update): @@ -564,7 +523,7 @@ async def test_connection_closed_during_update_can_recover( async def test_send_key(hass: HomeAssistant, remote: Mock) -> None: """Test for send key.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) assert await hass.services.async_call( DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True ) @@ -579,7 +538,7 @@ async def test_send_key(hass: HomeAssistant, remote: Mock) -> None: async def test_send_key_broken_pipe(hass: HomeAssistant, remote: Mock) -> None: """Testing broken pipe Exception.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) remote.control = Mock(side_effect=BrokenPipeError("Boom")) assert await hass.services.async_call( DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True @@ -592,7 +551,7 @@ async def test_send_key_connection_closed_retry_succeed( hass: HomeAssistant, remote: Mock ) -> None: """Test retry on connection closed.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) remote.control = Mock( side_effect=[exceptions.ConnectionClosed("Boom"), DEFAULT_MOCK, DEFAULT_MOCK] ) @@ -613,7 +572,7 @@ async def test_send_key_connection_closed_retry_succeed( async def test_send_key_unhandled_response(hass: HomeAssistant, remote: Mock) -> None: """Testing unhandled response exception.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) remote.control = Mock(side_effect=exceptions.UnhandledResponse("Boom")) assert await hass.services.async_call( DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True @@ -625,7 +584,7 @@ async def test_send_key_unhandled_response(hass: HomeAssistant, remote: Mock) -> @pytest.mark.usefixtures("rest_api") async def test_send_key_websocketexception(hass: HomeAssistant, remotews: Mock) -> None: """Testing unhandled response exception.""" - await setup_samsungtv(hass, MOCK_CONFIGWS) + await setup_samsungtv_entry(hass, MOCK_CONFIGWS) remotews.send_commands = Mock(side_effect=WebSocketException("Boom")) assert await hass.services.async_call( DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True @@ -651,7 +610,7 @@ async def test_send_key_websocketexception_encrypted( @pytest.mark.usefixtures("rest_api") async def test_send_key_os_error_ws(hass: HomeAssistant, remotews: Mock) -> None: """Testing unhandled response exception.""" - await setup_samsungtv(hass, MOCK_CONFIGWS) + await setup_samsungtv_entry(hass, MOCK_CONFIGWS) remotews.send_commands = Mock(side_effect=OSError("Boom")) assert await hass.services.async_call( DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True @@ -676,7 +635,7 @@ async def test_send_key_os_error_ws_encrypted( async def test_send_key_os_error(hass: HomeAssistant, remote: Mock) -> None: """Testing broken pipe Exception.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) remote.control = Mock(side_effect=OSError("Boom")) assert await hass.services.async_call( DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True @@ -688,42 +647,24 @@ async def test_send_key_os_error(hass: HomeAssistant, remote: Mock) -> None: @pytest.mark.usefixtures("remote") async def test_name(hass: HomeAssistant) -> None: """Test for name property.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) state = hass.states.get(ENTITY_ID) assert state.attributes[ATTR_FRIENDLY_NAME] == "fake" @pytest.mark.usefixtures("remote") -async def test_state_with_turnon(hass: HomeAssistant, delay: Mock) -> None: +async def test_state(hass: HomeAssistant) -> None: """Test for state property.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) assert await hass.services.async_call( - DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_ID}, True + DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True ) state = hass.states.get(ENTITY_ID) assert state.state == STATE_ON - assert delay.call_count == 1 - assert await hass.services.async_call( DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, True ) state = hass.states.get(ENTITY_ID) - assert state.state == STATE_OFF - - -@pytest.mark.usefixtures("remote") -async def test_state_without_turnon(hass: HomeAssistant) -> None: - """Test for state property.""" - await setup_samsungtv(hass, MOCK_CONFIG_NOTURNON) - assert await hass.services.async_call( - DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID_NOTURNON}, True - ) - state = hass.states.get(ENTITY_ID_NOTURNON) - assert state.state == STATE_ON - assert await hass.services.async_call( - DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID_NOTURNON}, True - ) - state = hass.states.get(ENTITY_ID_NOTURNON) # Should be STATE_UNAVAILABLE after the timer expires assert state.state == STATE_OFF @@ -735,34 +676,23 @@ async def test_state_without_turnon(hass: HomeAssistant) -> None: async_fire_time_changed(hass, next_update) await hass.async_block_till_done() - state = hass.states.get(ENTITY_ID_NOTURNON) + state = hass.states.get(ENTITY_ID) # Should be STATE_UNAVAILABLE since there is no way to turn it back on assert state.state == STATE_UNAVAILABLE @pytest.mark.usefixtures("remote") -async def test_supported_features_with_turnon(hass: HomeAssistant) -> None: +async def test_supported_features(hass: HomeAssistant) -> None: """Test for supported_features property.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) state = hass.states.get(ENTITY_ID) - assert ( - state.attributes[ATTR_SUPPORTED_FEATURES] - == SUPPORT_SAMSUNGTV | MediaPlayerEntityFeature.TURN_ON - ) - - -@pytest.mark.usefixtures("remote") -async def test_supported_features_without_turnon(hass: HomeAssistant) -> None: - """Test for supported_features property.""" - await setup_samsungtv(hass, MOCK_CONFIG_NOTURNON) - state = hass.states.get(ENTITY_ID_NOTURNON) assert state.attributes[ATTR_SUPPORTED_FEATURES] == SUPPORT_SAMSUNGTV @pytest.mark.usefixtures("remote") async def test_device_class(hass: HomeAssistant) -> None: """Test for device_class property.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) state = hass.states.get(ENTITY_ID) assert state.attributes[ATTR_DEVICE_CLASS] is MediaPlayerDeviceClass.TV.value @@ -777,7 +707,7 @@ async def test_turn_off_websocket( "homeassistant.components.samsungtv.bridge.Remote", side_effect=[OSError("Boom"), DEFAULT_MOCK], ): - await setup_samsungtv(hass, MOCK_CONFIGWS) + await setup_samsungtv_entry(hass, MOCK_CONFIGWS) remotews.send_commands.reset_mock() @@ -816,7 +746,7 @@ async def test_turn_off_websocket_frame( "homeassistant.components.samsungtv.bridge.Remote", side_effect=[OSError("Boom"), DEFAULT_MOCK], ): - await setup_samsungtv(hass, MOCK_CONFIGWS) + await setup_samsungtv_entry(hass, MOCK_CONFIGWS) remotews.send_commands.reset_mock() @@ -903,9 +833,9 @@ async def test_turn_off_encrypted_websocket_key_type( async def test_turn_off_legacy(hass: HomeAssistant, remote: Mock) -> None: """Test for turn_off.""" - await setup_samsungtv(hass, MOCK_CONFIG_NOTURNON) + await setup_samsungtv_entry(hass, MOCK_CONFIG) assert await hass.services.async_call( - DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID_NOTURNON}, True + DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, True ) # key called assert remote.control.call_count == 1 @@ -917,7 +847,7 @@ async def test_turn_off_os_error( ) -> None: """Test for turn_off with OSError.""" caplog.set_level(logging.DEBUG) - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) remote.close = Mock(side_effect=OSError("BOOM")) assert await hass.services.async_call( DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, True @@ -931,7 +861,7 @@ async def test_turn_off_ws_os_error( ) -> None: """Test for turn_off with OSError.""" caplog.set_level(logging.DEBUG) - await setup_samsungtv(hass, MOCK_CONFIGWS) + await setup_samsungtv_entry(hass, MOCK_CONFIGWS) remotews.close = Mock(side_effect=OSError("BOOM")) assert await hass.services.async_call( DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, True @@ -955,7 +885,7 @@ async def test_turn_off_encryptedws_os_error( async def test_volume_up(hass: HomeAssistant, remote: Mock) -> None: """Test for volume_up.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) assert await hass.services.async_call( DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True ) @@ -968,7 +898,7 @@ async def test_volume_up(hass: HomeAssistant, remote: Mock) -> None: async def test_volume_down(hass: HomeAssistant, remote: Mock) -> None: """Test for volume_down.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) assert await hass.services.async_call( DOMAIN, SERVICE_VOLUME_DOWN, {ATTR_ENTITY_ID: ENTITY_ID}, True ) @@ -981,7 +911,7 @@ async def test_volume_down(hass: HomeAssistant, remote: Mock) -> None: async def test_mute_volume(hass: HomeAssistant, remote: Mock) -> None: """Test for mute_volume.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) assert await hass.services.async_call( DOMAIN, SERVICE_VOLUME_MUTE, @@ -997,7 +927,7 @@ async def test_mute_volume(hass: HomeAssistant, remote: Mock) -> None: async def test_media_play(hass: HomeAssistant, remote: Mock) -> None: """Test for media_play.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) assert await hass.services.async_call( DOMAIN, SERVICE_MEDIA_PLAY, {ATTR_ENTITY_ID: ENTITY_ID}, True ) @@ -1019,7 +949,7 @@ async def test_media_play(hass: HomeAssistant, remote: Mock) -> None: async def test_media_pause(hass: HomeAssistant, remote: Mock) -> None: """Test for media_pause.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) assert await hass.services.async_call( DOMAIN, SERVICE_MEDIA_PAUSE, {ATTR_ENTITY_ID: ENTITY_ID}, True ) @@ -1041,7 +971,7 @@ async def test_media_pause(hass: HomeAssistant, remote: Mock) -> None: async def test_media_next_track(hass: HomeAssistant, remote: Mock) -> None: """Test for media_next_track.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) assert await hass.services.async_call( DOMAIN, SERVICE_MEDIA_NEXT_TRACK, {ATTR_ENTITY_ID: ENTITY_ID}, True ) @@ -1054,7 +984,7 @@ async def test_media_next_track(hass: HomeAssistant, remote: Mock) -> None: async def test_media_previous_track(hass: HomeAssistant, remote: Mock) -> None: """Test for media_previous_track.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) assert await hass.services.async_call( DOMAIN, SERVICE_MEDIA_PREVIOUS_TRACK, {ATTR_ENTITY_ID: ENTITY_ID}, True ) @@ -1065,16 +995,6 @@ async def test_media_previous_track(hass: HomeAssistant, remote: Mock) -> None: assert remote.close.call_args_list == [call()] -@pytest.mark.usefixtures("remote") -async def test_turn_on_with_turnon(hass: HomeAssistant, delay: Mock) -> None: - """Test turn on.""" - await setup_samsungtv(hass, MOCK_CONFIG) - assert await hass.services.async_call( - DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_ID}, True - ) - assert delay.call_count == 1 - - @pytest.mark.usefixtures("remotews", "rest_api") async def test_turn_on_wol(hass: HomeAssistant) -> None: """Test turn on.""" @@ -1084,7 +1004,7 @@ async def test_turn_on_wol(hass: HomeAssistant) -> None: unique_id="any", ) entry.add_to_hass(hass) - assert await async_setup_component(hass, SAMSUNGTV_DOMAIN, {}) + assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() with patch( "homeassistant.components.samsungtv.media_player.send_magic_packet" @@ -1098,10 +1018,10 @@ async def test_turn_on_wol(hass: HomeAssistant) -> None: async def test_turn_on_without_turnon(hass: HomeAssistant, remote: Mock) -> None: """Test turn on.""" - await setup_samsungtv(hass, MOCK_CONFIG_NOTURNON) + await setup_samsungtv_entry(hass, MOCK_CONFIG) with pytest.raises(HomeAssistantError): await hass.services.async_call( - DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_ID_NOTURNON}, True + DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_ID}, True ) # nothing called as not supported feature assert remote.control.call_count == 0 @@ -1109,7 +1029,7 @@ async def test_turn_on_without_turnon(hass: HomeAssistant, remote: Mock) -> None async def test_play_media(hass: HomeAssistant, remote: Mock) -> None: """Test for play_media.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) with patch("homeassistant.components.samsungtv.bridge.asyncio.sleep") as sleep: assert await hass.services.async_call( DOMAIN, @@ -1138,7 +1058,7 @@ async def test_play_media_invalid_type(hass: HomeAssistant) -> None: """Test for play_media with invalid media type.""" with patch("homeassistant.components.samsungtv.bridge.Remote") as remote: url = "https://example.com" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) remote.reset_mock() assert await hass.services.async_call( DOMAIN, @@ -1160,7 +1080,7 @@ async def test_play_media_channel_as_string(hass: HomeAssistant) -> None: """Test for play_media with invalid channel as string.""" with patch("homeassistant.components.samsungtv.bridge.Remote") as remote: url = "https://example.com" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) remote.reset_mock() assert await hass.services.async_call( DOMAIN, @@ -1181,7 +1101,7 @@ async def test_play_media_channel_as_string(hass: HomeAssistant) -> None: async def test_play_media_channel_as_non_positive(hass: HomeAssistant) -> None: """Test for play_media with invalid channel as non positive integer.""" with patch("homeassistant.components.samsungtv.bridge.Remote") as remote: - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) remote.reset_mock() assert await hass.services.async_call( DOMAIN, @@ -1201,7 +1121,7 @@ async def test_play_media_channel_as_non_positive(hass: HomeAssistant) -> None: async def test_select_source(hass: HomeAssistant, remote: Mock) -> None: """Test for select_source.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) assert await hass.services.async_call( DOMAIN, SERVICE_SELECT_SOURCE, @@ -1218,7 +1138,7 @@ async def test_select_source(hass: HomeAssistant, remote: Mock) -> None: async def test_select_source_invalid_source(hass: HomeAssistant) -> None: """Test for select_source with invalid source.""" with patch("homeassistant.components.samsungtv.bridge.Remote") as remote: - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) remote.reset_mock() assert await hass.services.async_call( DOMAIN, @@ -1235,7 +1155,7 @@ async def test_select_source_invalid_source(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("rest_api") async def test_play_media_app(hass: HomeAssistant, remotews: Mock) -> None: """Test for play_media.""" - await setup_samsungtv(hass, MOCK_CONFIGWS) + await setup_samsungtv_entry(hass, MOCK_CONFIGWS) remotews.send_commands.reset_mock() assert await hass.services.async_call( @@ -1259,7 +1179,7 @@ async def test_play_media_app(hass: HomeAssistant, remotews: Mock) -> None: async def test_select_source_app(hass: HomeAssistant, remotews: Mock) -> None: """Test for select_source.""" remotews.app_list_data = SAMPLE_EVENT_ED_INSTALLED_APP - await setup_samsungtv(hass, MOCK_CONFIGWS) + await setup_samsungtv_entry(hass, MOCK_CONFIGWS) remotews.send_commands.reset_mock() assert await hass.services.async_call( From 2721874f13fc5047517e80cb6493399da40f0e1a Mon Sep 17 00:00:00 2001 From: erikbadman Date: Tue, 23 May 2023 11:42:54 +0200 Subject: [PATCH 462/822] Get kostal plenticore hostname id from get_settings (#93008) * Get hostname id from get_settings * Add try except in get_hostname_id function * Update tests after adding get_hostname_id function * Revert "Update tests after adding get_hostname_id function" This reverts commit 5fa4e533cb18e8d141dbb1b7aed021f80f4312a2. * Add test for G2 models in config flow. * Add test for helper module. * Fix test for numbers. * Revert "Add try except in get_hostname_id function" This reverts commit 059f5bd9b413ca060300e09a6a1f0dffb4420f56. * Update variable name with known hostname ids to be private --------- Co-authored-by: Stefan Gmeiner --- .../kostal_plenticore/config_flow.py | 6 +- .../components/kostal_plenticore/helper.py | 15 ++- .../kostal_plenticore/test_config_flow.py | 118 +++++++++++++++--- .../kostal_plenticore/test_helper.py | 107 ++++++++++++++++ .../kostal_plenticore/test_number.py | 32 ++++- 5 files changed, 252 insertions(+), 26 deletions(-) create mode 100644 tests/components/kostal_plenticore/test_helper.py diff --git a/homeassistant/components/kostal_plenticore/config_flow.py b/homeassistant/components/kostal_plenticore/config_flow.py index cbbaeefd85dc..ba8e762763dc 100644 --- a/homeassistant/components/kostal_plenticore/config_flow.py +++ b/homeassistant/components/kostal_plenticore/config_flow.py @@ -12,6 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN +from .helper import get_hostname_id _LOGGER = logging.getLogger(__name__) @@ -32,9 +33,10 @@ async def test_connection(hass: HomeAssistant, data) -> str: session = async_get_clientsession(hass) async with ApiClient(session, data["host"]) as client: await client.login(data["password"]) - values = await client.get_setting_values("scb:network", "Hostname") + hostname_id = await get_hostname_id(client) + values = await client.get_setting_values("scb:network", hostname_id) - return values["scb:network"]["Hostname"] + return values["scb:network"][hostname_id] class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): diff --git a/homeassistant/components/kostal_plenticore/helper.py b/homeassistant/components/kostal_plenticore/helper.py index a91fb24aad7e..35ec7bb94565 100644 --- a/homeassistant/components/kostal_plenticore/helper.py +++ b/homeassistant/components/kostal_plenticore/helper.py @@ -23,6 +23,7 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) _DataT = TypeVar("_DataT") +_KNOWN_HOSTNAME_IDS = ("Network:Hostname", "Hostname") class Plenticore: @@ -69,6 +70,7 @@ class Plenticore: ) # get some device meta data + hostname_id = await get_hostname_id(self._client) settings = await self._client.get_setting_values( { "devices:local": [ @@ -78,7 +80,7 @@ class Plenticore: "Properties:VersionIOC", "Properties:VersionMC", ], - "scb:network": ["Hostname"], + "scb:network": [hostname_id], } ) @@ -91,7 +93,7 @@ class Plenticore: identifiers={(DOMAIN, device_local["Properties:SerialNo"])}, manufacturer="Kostal", model=f"{prod1} {prod2}", - name=settings["scb:network"]["Hostname"], + name=settings["scb:network"][hostname_id], sw_version=f'IOC: {device_local["Properties:VersionIOC"]}' + f' MC: {device_local["Properties:VersionMC"]}', ) @@ -403,3 +405,12 @@ class PlenticoreDataFormatter: return state return PlenticoreDataFormatter.EM_STATES.get(value) + + +async def get_hostname_id(client: ApiClient) -> str: + """Check for known existing hostname ids.""" + all_settings = await client.get_settings() + for entry in all_settings["scb:network"]: + if entry.id in _KNOWN_HOSTNAME_IDS: + return entry.id + raise ApiException("Hostname identifier not found in KNOWN_HOSTNAME_IDS") diff --git a/tests/components/kostal_plenticore/test_config_flow.py b/tests/components/kostal_plenticore/test_config_flow.py index 3c64a48c218d..41facfe9c260 100644 --- a/tests/components/kostal_plenticore/test_config_flow.py +++ b/tests/components/kostal_plenticore/test_config_flow.py @@ -1,8 +1,10 @@ """Test the Kostal Plenticore Solar Inverter config flow.""" import asyncio +from collections.abc import Generator from unittest.mock import ANY, AsyncMock, MagicMock, patch -from pykoplenti import AuthenticationException +from pykoplenti import ApiClient, AuthenticationException, SettingsData +import pytest from homeassistant import config_entries from homeassistant.components.kostal_plenticore.const import DOMAIN @@ -11,8 +13,33 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry -async def test_formx(hass: HomeAssistant) -> None: - """Test we get the form.""" +@pytest.fixture +def mock_apiclient() -> ApiClient: + """Return a mocked ApiClient instance.""" + apiclient = MagicMock(spec=ApiClient) + apiclient.__aenter__.return_value = apiclient + apiclient.__aexit__ = AsyncMock() + + return apiclient + + +@pytest.fixture +def mock_apiclient_class(mock_apiclient) -> Generator[type[ApiClient], None, None]: + """Return a mocked ApiClient class.""" + with patch( + "homeassistant.components.kostal_plenticore.config_flow.ApiClient", + autospec=True, + ) as mock_api_class: + mock_api_class.return_value = mock_apiclient + yield mock_api_class + + +async def test_form_g1( + hass: HomeAssistant, + mock_apiclient_class: type[ApiClient], + mock_apiclient: ApiClient, +) -> None: + """Test the config flow for G1 models.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -21,25 +48,19 @@ async def test_formx(hass: HomeAssistant) -> None: assert result["errors"] == {} with patch( - "homeassistant.components.kostal_plenticore.config_flow.ApiClient" - ) as mock_api_class, patch( "homeassistant.components.kostal_plenticore.async_setup_entry", return_value=True, ) as mock_setup_entry: # mock of the context manager instance - mock_api_ctx = MagicMock() - mock_api_ctx.login = AsyncMock() - mock_api_ctx.get_setting_values = AsyncMock( + mock_apiclient.login = AsyncMock() + mock_apiclient.get_settings = AsyncMock( + return_value={"scb:network": [SettingsData({"id": "Hostname"})]} + ) + mock_apiclient.get_setting_values = AsyncMock( + # G1 model has the entry id "Hostname" return_value={"scb:network": {"Hostname": "scb"}} ) - # mock of the return instance of ApiClient - mock_api = MagicMock() - mock_api.__aenter__.return_value = mock_api_ctx - mock_api.__aexit__ = AsyncMock() - - mock_api_class.return_value = mock_api - result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -49,11 +70,68 @@ async def test_formx(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - mock_api_class.assert_called_once_with(ANY, "1.1.1.1") - mock_api.__aenter__.assert_called_once() - mock_api.__aexit__.assert_called_once() - mock_api_ctx.login.assert_called_once_with("test-password") - mock_api_ctx.get_setting_values.assert_called_once() + mock_apiclient_class.assert_called_once_with(ANY, "1.1.1.1") + mock_apiclient.__aenter__.assert_called_once() + mock_apiclient.__aexit__.assert_called_once() + mock_apiclient.login.assert_called_once_with("test-password") + mock_apiclient.get_settings.assert_called_once() + mock_apiclient.get_setting_values.assert_called_once_with( + "scb:network", "Hostname" + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == "scb" + assert result2["data"] == { + "host": "1.1.1.1", + "password": "test-password", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_g2( + hass: HomeAssistant, + mock_apiclient_class: type[ApiClient], + mock_apiclient: ApiClient, +) -> None: + """Test the config flow for G2 models.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + with patch( + "homeassistant.components.kostal_plenticore.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + # mock of the context manager instance + mock_apiclient.login = AsyncMock() + mock_apiclient.get_settings = AsyncMock( + return_value={"scb:network": [SettingsData({"id": "Network:Hostname"})]} + ) + mock_apiclient.get_setting_values = AsyncMock( + # G1 model has the entry id "Hostname" + return_value={"scb:network": {"Network:Hostname": "scb"}} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "password": "test-password", + }, + ) + await hass.async_block_till_done() + + mock_apiclient_class.assert_called_once_with(ANY, "1.1.1.1") + mock_apiclient.__aenter__.assert_called_once() + mock_apiclient.__aexit__.assert_called_once() + mock_apiclient.login.assert_called_once_with("test-password") + mock_apiclient.get_settings.assert_called_once() + mock_apiclient.get_setting_values.assert_called_once_with( + "scb:network", "Network:Hostname" + ) assert result2["type"] == "create_entry" assert result2["title"] == "scb" diff --git a/tests/components/kostal_plenticore/test_helper.py b/tests/components/kostal_plenticore/test_helper.py new file mode 100644 index 000000000000..cc522c96974d --- /dev/null +++ b/tests/components/kostal_plenticore/test_helper.py @@ -0,0 +1,107 @@ +"""Test Kostal Plenticore helper.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +from pykoplenti import ApiClient, SettingsData +import pytest + +from homeassistant.components.kostal_plenticore.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_apiclient() -> Generator[ApiClient, None, None]: + """Return a mocked ApiClient class.""" + with patch( + "homeassistant.components.kostal_plenticore.helper.ApiClient", + autospec=True, + ) as mock_api_class: + apiclient = MagicMock(spec=ApiClient) + apiclient.__aenter__.return_value = apiclient + apiclient.__aexit__ = AsyncMock() + mock_api_class.return_value = apiclient + yield apiclient + + +async def test_plenticore_async_setup_g1( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_apiclient: ApiClient, +) -> None: + """Tests the async_setup() method of the Plenticore class for G1 models.""" + mock_apiclient.get_settings = AsyncMock( + return_value={"scb:network": [SettingsData({"id": "Hostname"})]} + ) + mock_apiclient.get_setting_values = AsyncMock( + # G1 model has the entry id "Hostname" + return_value={ + "devices:local": { + "Properties:SerialNo": "12345", + "Branding:ProductName1": "PLENTICORE", + "Branding:ProductName2": "plus 10", + "Properties:VersionIOC": "01.45", + "Properties:VersionMC": "01.46", + }, + "scb:network": {"Hostname": "scb"}, + } + ) + + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + plenticore = hass.data[DOMAIN][mock_config_entry.entry_id] + + assert plenticore.device_info == DeviceInfo( + configuration_url="http://192.168.1.2", + identifiers={(DOMAIN, "12345")}, + manufacturer="Kostal", + model="PLENTICORE plus 10", + name="scb", + sw_version="IOC: 01.45 MC: 01.46", + ) + + +async def test_plenticore_async_setup_g2( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_apiclient: ApiClient, +) -> None: + """Tests the async_setup() method of the Plenticore class for G2 models.""" + mock_apiclient.get_settings = AsyncMock( + return_value={"scb:network": [SettingsData({"id": "Network:Hostname"})]} + ) + mock_apiclient.get_setting_values = AsyncMock( + # G1 model has the entry id "Hostname" + return_value={ + "devices:local": { + "Properties:SerialNo": "12345", + "Branding:ProductName1": "PLENTICORE", + "Branding:ProductName2": "plus 10", + "Properties:VersionIOC": "01.45", + "Properties:VersionMC": "01.46", + }, + "scb:network": {"Network:Hostname": "scb"}, + } + ) + + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + plenticore = hass.data[DOMAIN][mock_config_entry.entry_id] + + assert plenticore.device_info == DeviceInfo( + configuration_url="http://192.168.1.2", + identifiers={(DOMAIN, "12345")}, + manufacturer="Kostal", + model="PLENTICORE plus 10", + name="scb", + sw_version="IOC: 01.45 MC: 01.46", + ) diff --git a/tests/components/kostal_plenticore/test_number.py b/tests/components/kostal_plenticore/test_number.py index beabd8fe669c..009184a699cd 100644 --- a/tests/components/kostal_plenticore/test_number.py +++ b/tests/components/kostal_plenticore/test_number.py @@ -62,7 +62,20 @@ def mock_get_setting_values(mock_plenticore_client: ApiClient) -> list: "id": "Battery:MinHomeComsumption", } ), - ] + ], + "scb:network": [ + SettingsData( + { + "min": "1", + "default": None, + "access": "readwrite", + "unit": None, + "id": "Hostname", + "type": "string", + "max": "63", + } + ) + ], } # this values are always retrieved by the integration on startup @@ -112,7 +125,22 @@ async def test_setup_no_entries( ) -> None: """Test that no entries are setup if Plenticore does not provide data.""" - mock_plenticore_client.get_settings.return_value = [] + # remove all settings except hostname which is used during setup + mock_plenticore_client.get_settings.return_value = { + "scb:network": [ + SettingsData( + { + "min": "1", + "default": None, + "access": "readwrite", + "unit": None, + "id": "Hostname", + "type": "string", + "max": "63", + } + ) + ], + } mock_config_entry.add_to_hass(hass) From c23718d13db00bbbf5189ddc0dccdcfbdd35ee31 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 23 May 2023 12:06:19 +0200 Subject: [PATCH 463/822] Cleanup samsungtv strings (#93395) --- homeassistant/components/samsungtv/strings.json | 6 ------ 1 file changed, 6 deletions(-) diff --git a/homeassistant/components/samsungtv/strings.json b/homeassistant/components/samsungtv/strings.json index cfa04244e829..f1f237fa4fb0 100644 --- a/homeassistant/components/samsungtv/strings.json +++ b/homeassistant/components/samsungtv/strings.json @@ -44,11 +44,5 @@ "trigger_type": { "samsungtv.turn_on": "Device is requested to turn on" } - }, - "issues": { - "deprecated_yaml": { - "title": "The SamsungTV YAML configuration is being removed", - "description": "Configuring SamsungTV using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the SamsungTV YAML configuration from your `configuration.yaml` file and restart Home Assistant to fix this issue.\n\nPlease note that previously configured `turn_on_action` needs to be manually converted to use the `turn_on` trigger ([documentation]({on_action_url}))." - } } } From 497fa1980bc613c323b51bb90e118959b2e437b8 Mon Sep 17 00:00:00 2001 From: RenierM26 <66512715+RenierM26@users.noreply.github.com> Date: Tue, 23 May 2023 12:32:14 +0200 Subject: [PATCH 464/822] Add Ezviz update entity (#85377) Co-authored-by: Franck Nijhof --- .coveragerc | 1 + homeassistant/components/ezviz/__init__.py | 1 + .../components/ezviz/binary_sensor.py | 4 - homeassistant/components/ezviz/manifest.json | 2 +- homeassistant/components/ezviz/update.py | 109 ++++++++++++++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 114 insertions(+), 7 deletions(-) create mode 100644 homeassistant/components/ezviz/update.py diff --git a/.coveragerc b/.coveragerc index 6f3b1b092cfa..792e0c3785ca 100644 --- a/.coveragerc +++ b/.coveragerc @@ -330,6 +330,7 @@ omit = homeassistant/components/ezviz/entity.py homeassistant/components/ezviz/sensor.py homeassistant/components/ezviz/switch.py + homeassistant/components/ezviz/update.py homeassistant/components/faa_delays/__init__.py homeassistant/components/faa_delays/binary_sensor.py homeassistant/components/familyhub/camera.py diff --git a/homeassistant/components/ezviz/__init__.py b/homeassistant/components/ezviz/__init__.py index 489ff97eb4a6..05857abbac7b 100644 --- a/homeassistant/components/ezviz/__init__.py +++ b/homeassistant/components/ezviz/__init__.py @@ -37,6 +37,7 @@ PLATFORMS_BY_TYPE: dict[str, list] = { Platform.CAMERA, Platform.SENSOR, Platform.SWITCH, + Platform.UPDATE, ], } diff --git a/homeassistant/components/ezviz/binary_sensor.py b/homeassistant/components/ezviz/binary_sensor.py index bab6fa5ca979..77e95fa221d0 100644 --- a/homeassistant/components/ezviz/binary_sensor.py +++ b/homeassistant/components/ezviz/binary_sensor.py @@ -25,10 +25,6 @@ BINARY_SENSOR_TYPES: dict[str, BinarySensorEntityDescription] = { key="alarm_schedules_enabled" ), "encrypted": BinarySensorEntityDescription(key="encrypted"), - "upgrade_available": BinarySensorEntityDescription( - key="upgrade_available", - device_class=BinarySensorDeviceClass.UPDATE, - ), } diff --git a/homeassistant/components/ezviz/manifest.json b/homeassistant/components/ezviz/manifest.json index 5dfeae4bae9a..219f4c87d13b 100644 --- a/homeassistant/components/ezviz/manifest.json +++ b/homeassistant/components/ezviz/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/ezviz", "iot_class": "cloud_polling", "loggers": ["paho_mqtt", "pyezviz"], - "requirements": ["pyezviz==0.2.0.9"] + "requirements": ["pyezviz==0.2.0.12"] } diff --git a/homeassistant/components/ezviz/update.py b/homeassistant/components/ezviz/update.py new file mode 100644 index 000000000000..3acc10325147 --- /dev/null +++ b/homeassistant/components/ezviz/update.py @@ -0,0 +1,109 @@ +"""Support for EZVIZ sensors.""" +from __future__ import annotations + +from typing import Any + +from pyezviz import HTTPError, PyEzvizError + +from homeassistant.components.update import ( + UpdateDeviceClass, + UpdateEntity, + UpdateEntityDescription, + UpdateEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DATA_COORDINATOR, DOMAIN +from .coordinator import EzvizDataUpdateCoordinator +from .entity import EzvizEntity + +PARALLEL_UPDATES = 1 + +UPDATE_ENTITY_TYPES = UpdateEntityDescription( + key="version", + name="Firmware update", + device_class=UpdateDeviceClass.FIRMWARE, +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up EZVIZ sensors based on a config entry.""" + coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ + DATA_COORDINATOR + ] + + async_add_entities( + EzvizUpdateEntity(coordinator, camera, sensor, UPDATE_ENTITY_TYPES) + for camera in coordinator.data + for sensor, value in coordinator.data[camera].items() + if sensor in UPDATE_ENTITY_TYPES.key + if value + ) + + +class EzvizUpdateEntity(EzvizEntity, UpdateEntity): + """Representation of a EZVIZ Update entity.""" + + _attr_has_entity_name = True + _attr_supported_features = ( + UpdateEntityFeature.INSTALL + | UpdateEntityFeature.PROGRESS + | UpdateEntityFeature.RELEASE_NOTES + ) + + def __init__( + self, + coordinator: EzvizDataUpdateCoordinator, + serial: str, + sensor: str, + description: UpdateEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, serial) + self._attr_unique_id = f"{serial}_{sensor}" + self.entity_description = description + + @property + def installed_version(self) -> str | None: + """Version installed and in use.""" + return self.data["version"] + + @property + def in_progress(self) -> bool | int | None: + """Update installation progress.""" + if self.data["upgrade_in_progress"]: + return self.data["upgrade_percent"] + return False + + @property + def latest_version(self) -> str | None: + """Latest version available for install.""" + if self.data["upgrade_available"]: + return self.data["latest_firmware_info"]["version"] + + return self.installed_version + + def release_notes(self) -> str | None: + """Return full release notes.""" + if self.data["latest_firmware_info"]: + return self.data["latest_firmware_info"].get("desc") + return None + + async def async_install( + self, version: str | None, backup: bool, **kwargs: Any + ) -> None: + """Install an update.""" + try: + await self.hass.async_add_executor_job( + self.coordinator.ezviz_client.upgrade_device, self._serial + ) + + except (HTTPError, PyEzvizError) as err: + raise HomeAssistantError( + f"Failed to update firmware on {self.name}" + ) from err diff --git a/requirements_all.txt b/requirements_all.txt index 414aff976ac9..28ac5cc662df 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1626,7 +1626,7 @@ pyeverlights==0.1.0 pyevilgenius==2.0.0 # homeassistant.components.ezviz -pyezviz==0.2.0.9 +pyezviz==0.2.0.12 # homeassistant.components.fibaro pyfibaro==0.7.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7370d2b45fac..b21e7dca4f9a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1187,7 +1187,7 @@ pyeverlights==0.1.0 pyevilgenius==2.0.0 # homeassistant.components.ezviz -pyezviz==0.2.0.9 +pyezviz==0.2.0.12 # homeassistant.components.fibaro pyfibaro==0.7.1 From bb8dfc94703d9795501dcbc99b2ad5da54699cc5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 May 2023 06:16:20 -0500 Subject: [PATCH 465/822] Bump protobuf to 4.23.1 (#93338) --- 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 0654587d2453..9f0e6e79d9b7 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -154,7 +154,7 @@ pyOpenSSL>=23.1.0 # protobuf must be in package constraints for the wheel # builder to build binary wheels -protobuf==4.22.3 +protobuf==4.23.1 # 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 594c78c81aa5..43aff2566fc7 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -158,7 +158,7 @@ pyOpenSSL>=23.1.0 # protobuf must be in package constraints for the wheel # builder to build binary wheels -protobuf==4.22.3 +protobuf==4.23.1 # 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 28fa6f541f99981ffba0369a1cb0da885fa76766 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 May 2023 07:18:26 -0500 Subject: [PATCH 466/822] Bump ifaddr to 0.2.0 (#93377) --- homeassistant/components/network/manifest.json | 2 +- homeassistant/components/network/models.py | 2 +- homeassistant/components/network/util.py | 3 +++ homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 8 files changed, 10 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/network/manifest.json b/homeassistant/components/network/manifest.json index f7e0bea2422b..a76da88914d6 100644 --- a/homeassistant/components/network/manifest.json +++ b/homeassistant/components/network/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["ifaddr==0.1.7"] + "requirements": ["ifaddr==0.2.0"] } diff --git a/homeassistant/components/network/models.py b/homeassistant/components/network/models.py index d3fbc824489c..4428578f8f90 100644 --- a/homeassistant/components/network/models.py +++ b/homeassistant/components/network/models.py @@ -24,7 +24,7 @@ class Adapter(TypedDict): """Configured network adapters.""" name: str - index: int + index: int | None enabled: bool auto: bool default: bool diff --git a/homeassistant/components/network/util.py b/homeassistant/components/network/util.py index 6f204b053976..2fb0690684c3 100644 --- a/homeassistant/components/network/util.py +++ b/homeassistant/components/network/util.py @@ -97,6 +97,7 @@ def _ifaddr_adapter_to_ha( ip_addr = ip_address(ip_config.ip[0]) ip_v6s.append(_ip_v6_from_adapter(ip_config)) else: + assert not isinstance(ip_config.ip, tuple) ip_addr = ip_address(ip_config.ip) ip_v4s.append(_ip_v4_from_adapter(ip_config)) @@ -117,6 +118,7 @@ def _ifaddr_adapter_to_ha( def _ip_v6_from_adapter(ip_config: ifaddr.IP) -> IPv6ConfiguredAddress: + assert isinstance(ip_config.ip, tuple) return { "address": ip_config.ip[0], "flowinfo": ip_config.ip[1], @@ -126,6 +128,7 @@ def _ip_v6_from_adapter(ip_config: ifaddr.IP) -> IPv6ConfiguredAddress: def _ip_v4_from_adapter(ip_config: ifaddr.IP) -> IPv4ConfiguredAddress: + assert not isinstance(ip_config.ip, tuple) return { "address": ip_config.ip, "network_prefix": ip_config.network_prefix, diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 9f0e6e79d9b7..a13a8f847900 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -28,7 +28,7 @@ home-assistant-bluetooth==1.10.0 home-assistant-frontend==20230503.3 home-assistant-intents==2023.4.26 httpx==0.24.0 -ifaddr==0.1.7 +ifaddr==0.2.0 janus==1.0.0 jinja2==3.1.2 lru-dict==1.1.8 diff --git a/pyproject.toml b/pyproject.toml index 1e69404f4cb5..1bda10f06777 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,7 @@ dependencies = [ # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.24.0", "home-assistant-bluetooth==1.10.0", - "ifaddr==0.1.7", + "ifaddr==0.2.0", "jinja2==3.1.2", "lru-dict==1.1.8", "PyJWT==2.7.0", diff --git a/requirements.txt b/requirements.txt index 12533b518474..ca8bd2631593 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ certifi>=2021.5.30 ciso8601==2.3.0 httpx==0.24.0 home-assistant-bluetooth==1.10.0 -ifaddr==0.1.7 +ifaddr==0.2.0 jinja2==3.1.2 lru-dict==1.1.8 PyJWT==2.7.0 diff --git a/requirements_all.txt b/requirements_all.txt index 28ac5cc662df..1c7e3e96618b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -965,7 +965,7 @@ ical==4.5.1 icmplib==3.0 # homeassistant.components.network -ifaddr==0.1.7 +ifaddr==0.2.0 # homeassistant.components.iglo iglo==1.2.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b21e7dca4f9a..cf4f88351a32 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -745,7 +745,7 @@ ical==4.5.1 icmplib==3.0 # homeassistant.components.network -ifaddr==0.1.7 +ifaddr==0.2.0 # homeassistant.components.influxdb influxdb-client==1.24.0 From f8f83906f84c5be2870b7ff1bab378640bc57805 Mon Sep 17 00:00:00 2001 From: rikroe <42204099+rikroe@users.noreply.github.com> Date: Tue, 23 May 2023 15:21:29 +0200 Subject: [PATCH 467/822] Add climate on/off for supported BMW vehicles (#92962) * Add switch platform * Add tests * Remove separate button * Bump coverage --------- Co-authored-by: rikroe --- .../bmw_connected_drive/__init__.py | 1 + .../components/bmw_connected_drive/button.py | 6 - .../components/bmw_connected_drive/switch.py | 109 ++++++++++++++++++ .../snapshots/test_switch.ambr | 17 +++ .../bmw_connected_drive/test_switch.py | 100 ++++++++++++++++ 5 files changed, 227 insertions(+), 6 deletions(-) create mode 100644 homeassistant/components/bmw_connected_drive/switch.py create mode 100644 tests/components/bmw_connected_drive/snapshots/test_switch.ambr create mode 100644 tests/components/bmw_connected_drive/test_switch.py diff --git a/homeassistant/components/bmw_connected_drive/__init__.py b/homeassistant/components/bmw_connected_drive/__init__.py index 8d5d842e915c..27f2d99cd2d7 100644 --- a/homeassistant/components/bmw_connected_drive/__init__.py +++ b/homeassistant/components/bmw_connected_drive/__init__.py @@ -44,6 +44,7 @@ PLATFORMS = [ Platform.NUMBER, Platform.SELECT, Platform.SENSOR, + Platform.SWITCH, ] SERVICE_UPDATE_STATE = "update_state" diff --git a/homeassistant/components/bmw_connected_drive/button.py b/homeassistant/components/bmw_connected_drive/button.py index 873a72762ab4..0ecc07357fed 100644 --- a/homeassistant/components/bmw_connected_drive/button.py +++ b/homeassistant/components/bmw_connected_drive/button.py @@ -53,12 +53,6 @@ BUTTON_TYPES: tuple[BMWButtonEntityDescription, ...] = ( name="Activate air conditioning", remote_function=lambda vehicle: vehicle.remote_services.trigger_remote_air_conditioning(), ), - BMWButtonEntityDescription( - key="deactivate_air_conditioning", - icon="mdi:hvac-off", - name="Deactivate air conditioning", - remote_function=lambda vehicle: vehicle.remote_services.trigger_remote_air_conditioning_stop(), - ), BMWButtonEntityDescription( key="find_vehicle", icon="mdi:crosshairs-question", diff --git a/homeassistant/components/bmw_connected_drive/switch.py b/homeassistant/components/bmw_connected_drive/switch.py new file mode 100644 index 000000000000..af7a42b35b0a --- /dev/null +++ b/homeassistant/components/bmw_connected_drive/switch.py @@ -0,0 +1,109 @@ +"""Switch platform for BMW.""" + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +import logging +from typing import Any + +from bimmer_connected.models import MyBMWAPIError +from bimmer_connected.vehicle import MyBMWVehicle + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import BMWBaseEntity +from .const import DOMAIN +from .coordinator import BMWDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class BMWRequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[MyBMWVehicle], bool] + remote_service_on: Callable[[MyBMWVehicle], Coroutine[Any, Any, Any]] + remote_service_off: Callable[[MyBMWVehicle], Coroutine[Any, Any, Any]] + + +@dataclass +class BMWSwitchEntityDescription(SwitchEntityDescription, BMWRequiredKeysMixin): + """Describes BMW switch entity.""" + + is_available: Callable[[MyBMWVehicle], bool] = lambda _: False + dynamic_options: Callable[[MyBMWVehicle], list[str]] | None = None + + +NUMBER_TYPES: list[BMWSwitchEntityDescription] = [ + BMWSwitchEntityDescription( + key="climate", + name="Climate", + is_available=lambda v: v.is_remote_climate_stop_enabled, + value_fn=lambda v: v.climate.is_climate_on, + remote_service_on=lambda v: v.remote_services.trigger_remote_air_conditioning(), + remote_service_off=lambda v: v.remote_services.trigger_remote_air_conditioning_stop(), + icon="mdi:fan", + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the MyBMW switch from config entry.""" + coordinator: BMWDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + entities: list[BMWSwitch] = [] + + for vehicle in coordinator.account.vehicles: + if not coordinator.read_only: + entities.extend( + [ + BMWSwitch(coordinator, vehicle, description) + for description in NUMBER_TYPES + if description.is_available(vehicle) + ] + ) + async_add_entities(entities) + + +class BMWSwitch(BMWBaseEntity, SwitchEntity): + """Representation of BMW Switch entity.""" + + entity_description: BMWSwitchEntityDescription + + def __init__( + self, + coordinator: BMWDataUpdateCoordinator, + vehicle: MyBMWVehicle, + description: BMWSwitchEntityDescription, + ) -> None: + """Initialize an BMW Switch.""" + super().__init__(coordinator, vehicle) + self.entity_description = description + self._attr_unique_id = f"{vehicle.vin}-{description.key}" + + @property + def is_on(self) -> bool: + """Return the entity value to represent the entity state.""" + return self.entity_description.value_fn(self.vehicle) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + try: + await self.entity_description.remote_service_on(self.vehicle) + except MyBMWAPIError as ex: + raise HomeAssistantError(ex) from ex + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + try: + await self.entity_description.remote_service_off(self.vehicle) + except MyBMWAPIError as ex: + raise HomeAssistantError(ex) from ex diff --git a/tests/components/bmw_connected_drive/snapshots/test_switch.ambr b/tests/components/bmw_connected_drive/snapshots/test_switch.ambr new file mode 100644 index 000000000000..23d7ec2e8335 --- /dev/null +++ b/tests/components/bmw_connected_drive/snapshots/test_switch.ambr @@ -0,0 +1,17 @@ +# serializer version: 1 +# name: test_entity_state_attrs + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i4 eDrive40 Climate', + 'icon': 'mdi:fan', + }), + 'context': , + 'entity_id': 'switch.i4_edrive40_climate', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + ]) +# --- diff --git a/tests/components/bmw_connected_drive/test_switch.py b/tests/components/bmw_connected_drive/test_switch.py new file mode 100644 index 000000000000..fd9871dfecde --- /dev/null +++ b/tests/components/bmw_connected_drive/test_switch.py @@ -0,0 +1,100 @@ +"""Test BMW switches.""" +from unittest.mock import AsyncMock + +from bimmer_connected.models import MyBMWAPIError, MyBMWRemoteServiceError +from bimmer_connected.vehicle.remote_services import RemoteServices +import pytest +import respx +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from . import setup_mocked_integration + + +async def test_entity_state_attrs( + hass: HomeAssistant, + bmw_fixture: respx.Router, + snapshot: SnapshotAssertion, +) -> None: + """Test switch options and values..""" + + # Setup component + assert await setup_mocked_integration(hass) + + # Get all switch entities + assert hass.states.async_all("switch") == snapshot + + +@pytest.mark.parametrize( + ("entity_id", "value"), + [ + ("switch.i4_edrive40_climate", "ON"), + ("switch.i4_edrive40_climate", "OFF"), + ], +) +async def test_update_triggers_success( + hass: HomeAssistant, + entity_id: str, + value: str, + bmw_fixture: respx.Router, +) -> None: + """Test allowed values for switch inputs.""" + + # Setup component + assert await setup_mocked_integration(hass) + + # Test + await hass.services.async_call( + "switch", + f"turn_{value.lower()}", + blocking=True, + target={"entity_id": entity_id}, + ) + assert RemoteServices.trigger_remote_service.call_count == 1 + + +@pytest.mark.parametrize( + ("raised", "expected"), + [ + (MyBMWRemoteServiceError, HomeAssistantError), + (MyBMWAPIError, HomeAssistantError), + (ValueError, ValueError), + ], +) +async def test_update_triggers_exceptions( + hass: HomeAssistant, + raised: Exception, + expected: Exception, + bmw_fixture: respx.Router, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test not allowed values for switch inputs.""" + + # Setup component + assert await setup_mocked_integration(hass) + + # Setup exception + monkeypatch.setattr( + RemoteServices, + "trigger_remote_service", + AsyncMock(side_effect=raised), + ) + + # Test + with pytest.raises(expected): + await hass.services.async_call( + "switch", + "turn_on", + blocking=True, + target={"entity_id": "switch.i4_edrive40_climate"}, + ) + with pytest.raises(expected): + await hass.services.async_call( + "switch", + "turn_off", + blocking=True, + target={"entity_id": "switch.i4_edrive40_climate"}, + ) + assert RemoteServices.trigger_remote_service.call_count == 2 From d62cdf3d659563ff568ba054026e89eff12c1d9d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 23 May 2023 15:54:24 +0200 Subject: [PATCH 468/822] Improve error message for sensors with a non-numeric value (#93399) * Improve error message for sensors with a non-numeric value * Address review comment --- homeassistant/components/sensor/__init__.py | 8 ++++---- tests/components/sensor/test_init.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 9b0a3b5e3d42..f21f57d9d36d 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -583,11 +583,11 @@ class SensorEntity(Entity): numerical_value = float(value) # type:ignore[arg-type] except (TypeError, ValueError) as err: raise ValueError( - f"Sensor {self.entity_id} has device class {device_class}, " - f"state class {state_class} unit {unit_of_measurement} and " - f"suggested precision {suggested_precision} thus indicating it " + f"Sensor {self.entity_id} has device class '{device_class}', " + f"state class '{state_class}' unit '{unit_of_measurement}' and " + f"suggested precision '{suggested_precision}' thus indicating it " f"has a numeric value; however, it has the non-numeric value: " - f"{value} ({type(value)})" + f"'{value}' ({type(value)})" ) from err else: numerical_value = value diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index 7c637b0a6f2b..adcbb8084b76 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -1842,7 +1842,7 @@ async def test_non_numeric_validation_error( assert ( "thus indicating it has a numeric value; " - f"however, it has the non-numeric value: {native_value}" + f"however, it has the non-numeric value: '{native_value}'" ) in caplog.text From e2fb1de0d916f3c752874edb39e40bd8f5ad45cf Mon Sep 17 00:00:00 2001 From: Tom Harris Date: Tue, 23 May 2023 11:31:17 -0400 Subject: [PATCH 469/822] Clean up Insteon config_flow (#93004) * Clean up config_flow * Remove unnecessary logging * Remove logger * Add more tests * Test closing modem connection * Simplify patching --- .../components/insteon/config_flow.py | 122 ++++--------- homeassistant/components/insteon/schemas.py | 16 +- homeassistant/components/insteon/strings.json | 16 +- homeassistant/components/insteon/utils.py | 31 ++++ tests/components/insteon/const.py | 3 + tests/components/insteon/test_config_flow.py | 172 +++++++++++++----- 6 files changed, 222 insertions(+), 138 deletions(-) diff --git a/homeassistant/components/insteon/config_flow.py b/homeassistant/components/insteon/config_flow.py index 60da74fdf019..15ce7c849e61 100644 --- a/homeassistant/components/insteon/config_flow.py +++ b/homeassistant/components/insteon/config_flow.py @@ -3,8 +3,7 @@ from __future__ import annotations import logging -from pyinsteon import async_connect -import voluptuous as vol +from pyinsteon import async_close, async_connect, devices from homeassistant import config_entries from homeassistant.components import dhcp, usb @@ -44,6 +43,7 @@ from .schemas import ( build_remove_x10_schema, build_x10_schema, ) +from .utils import async_get_usb_ports STEP_PLM = "plm" STEP_HUB_V1 = "hubv1" @@ -55,18 +55,10 @@ STEP_ADD_OVERRIDE = "add_override" STEP_REMOVE_OVERRIDE = "remove_override" STEP_REMOVE_X10 = "remove_x10" MODEM_TYPE = "modem_type" -PLM = "PowerLinc Modem (PLM)" -HUB1 = "Hub version 1 (pre-2014)" -HUB2 = "Hub version 2" _LOGGER = logging.getLogger(__name__) -def _only_one_selected(*args): - """Test if only one item is True.""" - return sum(args) == 1 - - async def _async_connect(**kwargs): """Connect to the Insteon modem.""" try: @@ -128,22 +120,10 @@ class InsteonFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_user(self, user_input=None): """Init the config flow.""" - errors = {} if self._async_current_entries(): return self.async_abort(reason="single_instance_allowed") - if user_input is not None: - selection = user_input.get(MODEM_TYPE) - - if selection == PLM: - return await self.async_step_plm() - if selection == HUB1: - return await self.async_step_hubv1() - return await self.async_step_hubv2() - modem_types = [PLM, HUB1, HUB2] - data_schema = vol.Schema({vol.Required(MODEM_TYPE): vol.In(modem_types)}) - return self.async_show_form( - step_id="user", data_schema=data_schema, errors=errors - ) + modem_types = [STEP_PLM, STEP_HUB_V1, STEP_HUB_V2] + return self.async_show_menu(step_id="user", menu_options=modem_types) async def async_step_plm(self, user_input=None): """Set up the PLM modem type.""" @@ -153,7 +133,8 @@ class InsteonFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self.async_create_entry(title="", data=user_input) errors["base"] = "cannot_connect" schema_defaults = user_input if user_input is not None else {} - data_schema = build_plm_schema(**schema_defaults) + ports = await async_get_usb_ports(self.hass) + data_schema = build_plm_schema(ports, **schema_defaults) return self.async_show_form( step_id=STEP_PLM, data_schema=data_schema, errors=errors ) @@ -243,57 +224,24 @@ class InsteonOptionsFlowHandler(config_entries.OptionsFlow): async def async_step_init(self, user_input=None) -> FlowResult: """Init the options config flow.""" - errors = {} - if user_input is not None: - change_hub_config = user_input.get(STEP_CHANGE_HUB_CONFIG, False) - change_plm_config = user_input.get(STEP_CHANGE_PLM_CONFIG, False) - device_override = user_input.get(STEP_ADD_OVERRIDE, False) - x10_device = user_input.get(STEP_ADD_X10, False) - remove_override = user_input.get(STEP_REMOVE_OVERRIDE, False) - remove_x10 = user_input.get(STEP_REMOVE_X10, False) - if _only_one_selected( - change_hub_config, - change_plm_config, - device_override, - x10_device, - remove_override, - remove_x10, - ): - if change_hub_config: - return await self.async_step_change_hub_config() - if change_plm_config: - return await self.async_step_change_plm_config() - if device_override: - return await self.async_step_add_override() - if x10_device: - return await self.async_step_add_x10() - if remove_override: - return await self.async_step_remove_override() - if remove_x10: - return await self.async_step_remove_x10() - errors["base"] = "select_single" + menu_options = [STEP_ADD_OVERRIDE, STEP_ADD_X10] - data_schema = { - vol.Optional(STEP_ADD_OVERRIDE): bool, - vol.Optional(STEP_ADD_X10): bool, - } if self.config_entry.data.get(CONF_HOST): - data_schema[vol.Optional(STEP_CHANGE_HUB_CONFIG)] = bool + menu_options.append(STEP_CHANGE_HUB_CONFIG) else: - data_schema[vol.Optional(STEP_CHANGE_PLM_CONFIG)] = bool + menu_options.append(STEP_CHANGE_PLM_CONFIG) options = {**self.config_entry.options} if options.get(CONF_OVERRIDE): - data_schema[vol.Optional(STEP_REMOVE_OVERRIDE)] = bool + menu_options.append(STEP_REMOVE_OVERRIDE) if options.get(CONF_X10): - data_schema[vol.Optional(STEP_REMOVE_X10)] = bool + menu_options.append(STEP_REMOVE_X10) - return self.async_show_form( - step_id="init", data_schema=vol.Schema(data_schema), errors=errors - ) + return self.async_show_menu(step_id="init", menu_options=menu_options) async def async_step_change_hub_config(self, user_input=None) -> FlowResult: """Change the Hub configuration.""" + errors = {} if user_input is not None: data = { **self.config_entry.data, @@ -303,31 +251,41 @@ class InsteonOptionsFlowHandler(config_entries.OptionsFlow): if self.config_entry.data[CONF_HUB_VERSION] == 2: data[CONF_USERNAME] = user_input[CONF_USERNAME] data[CONF_PASSWORD] = user_input[CONF_PASSWORD] - self.hass.config_entries.async_update_entry(self.config_entry, data=data) - return self.async_create_entry( - title="", - data={**self.config_entry.options}, - ) + if devices.modem: + await async_close() + + if await _async_connect(**data): + self.hass.config_entries.async_update_entry( + self.config_entry, data=data + ) + return self.async_create_entry(data={**self.config_entry.options}) + errors["base"] = "cannot_connect" data_schema = build_hub_schema(**self.config_entry.data) return self.async_show_form( - step_id=STEP_CHANGE_HUB_CONFIG, data_schema=data_schema + step_id=STEP_CHANGE_HUB_CONFIG, data_schema=data_schema, errors=errors ) async def async_step_change_plm_config(self, user_input=None) -> FlowResult: """Change the PLM configuration.""" + errors = {} if user_input is not None: data = { **self.config_entry.data, CONF_DEVICE: user_input[CONF_DEVICE], } - self.hass.config_entries.async_update_entry(self.config_entry, data=data) - return self.async_create_entry( - title="", - data={**self.config_entry.options}, - ) - data_schema = build_plm_schema(**self.config_entry.data) + if devices.modem: + await async_close() + if await _async_connect(**data): + self.hass.config_entries.async_update_entry( + self.config_entry, data=data + ) + return self.async_create_entry(data={**self.config_entry.options}) + errors["base"] = "cannot_connect" + + ports = await async_get_usb_ports(self.hass) + data_schema = build_plm_schema(ports, **self.config_entry.data) return self.async_show_form( - step_id=STEP_CHANGE_PLM_CONFIG, data_schema=data_schema + step_id=STEP_CHANGE_PLM_CONFIG, data_schema=data_schema, errors=errors ) async def async_step_add_override(self, user_input=None) -> FlowResult: @@ -337,7 +295,7 @@ class InsteonOptionsFlowHandler(config_entries.OptionsFlow): try: data = add_device_override({**self.config_entry.options}, user_input) async_dispatcher_send(self.hass, SIGNAL_ADD_DEVICE_OVERRIDE, user_input) - return self.async_create_entry(title="", data=data) + return self.async_create_entry(data=data) except ValueError: errors["base"] = "input_error" schema_defaults = user_input if user_input is not None else {} @@ -352,7 +310,7 @@ class InsteonOptionsFlowHandler(config_entries.OptionsFlow): if user_input is not None: options = add_x10_device({**self.config_entry.options}, user_input) async_dispatcher_send(self.hass, SIGNAL_ADD_X10_DEVICE, user_input) - return self.async_create_entry(title="", data=options) + return self.async_create_entry(data=options) schema_defaults: dict[str, str] = user_input if user_input is not None else {} data_schema = build_x10_schema(**schema_defaults) return self.async_show_form( @@ -370,7 +328,7 @@ class InsteonOptionsFlowHandler(config_entries.OptionsFlow): SIGNAL_REMOVE_DEVICE_OVERRIDE, user_input[CONF_ADDRESS], ) - return self.async_create_entry(title="", data=options) + return self.async_create_entry(data=options) data_schema = build_remove_override_schema(options[CONF_OVERRIDE]) return self.async_show_form( @@ -386,7 +344,7 @@ class InsteonOptionsFlowHandler(config_entries.OptionsFlow): async_dispatcher_send( self.hass, SIGNAL_REMOVE_X10_DEVICE, housecode, unitcode ) - return self.async_create_entry(title="", data=options) + return self.async_create_entry(data=options) data_schema = build_remove_x10_schema(options[CONF_X10]) return self.async_show_form( diff --git a/homeassistant/components/insteon/schemas.py b/homeassistant/components/insteon/schemas.py index 785aa90dd4a6..84b586e76494 100644 --- a/homeassistant/components/insteon/schemas.py +++ b/homeassistant/components/insteon/schemas.py @@ -285,9 +285,21 @@ def build_x10_schema( ) -def build_plm_schema(device=vol.UNDEFINED): +def _find_likely_port(ports): + """Return the most likely USB port for a PLM.""" + test_strings = ["FTDI", "0403:6001", "10BF:"] + for port, name in ports.items(): + for test_string in test_strings: + if test_string in name: + return port + return vol.UNDEFINED + + +def build_plm_schema(ports: dict[str, str], device=vol.UNDEFINED): """Build the PLM schema for config flow.""" - return vol.Schema({vol.Required(CONF_DEVICE, default=device): str}) + if not device or device == vol.UNDEFINED: + device = _find_likely_port(ports) + return vol.Schema({vol.Required(CONF_DEVICE, default=device): vol.In(ports)}) def build_hub_schema( diff --git a/homeassistant/components/insteon/strings.json b/homeassistant/components/insteon/strings.json index b302165ce6f1..a93ba4a7476a 100644 --- a/homeassistant/components/insteon/strings.json +++ b/homeassistant/components/insteon/strings.json @@ -4,8 +4,10 @@ "step": { "user": { "description": "Select the Insteon modem type.", - "data": { - "modem_type": "Modem type." + "menu_options": { + "plm": "PowerLink Modem (PLM)", + "hubv1": "Hub version 1 (pre 2014)", + "hubv2": "Hub version 2" } }, "plm": { @@ -38,8 +40,7 @@ } }, "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "select_single": "Select one option." + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" }, "abort": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -50,7 +51,7 @@ "options": { "step": { "init": { - "data": { + "menu_options": { "change_hub_config": "Change the Hub configuration.", "change_plm_config": "Change the PLM configuration.", "add_override": "Add a device override.", @@ -60,7 +61,7 @@ } }, "change_hub_config": { - "description": "Change the Insteon Hub connection information. You must restart Home Assistant after making this change. This does not change the configuration of the Hub itself. To change the configuration in the Hub use the Hub app.", + "description": "Change the Insteon Hub connection information.", "data": { "host": "[%key:common::config_flow::data::ip%]", "port": "[%key:common::config_flow::data::port%]", @@ -69,7 +70,7 @@ } }, "change_plm_config": { - "description": "Change the Insteon PLM connection information. You must restart Home Assistant after making this change. This does not change the configuration of the PLM itself.", + "description": "Change the Insteon PLM connection information.", "data": { "device": "[%key:common::config_flow::data::usb_path%]" } @@ -106,7 +107,6 @@ }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "select_single": "Select one option.", "input_error": "Invalid entries, please check your values." } } diff --git a/homeassistant/components/insteon/utils.py b/homeassistant/components/insteon/utils.py index 8915342d2e1c..58b2430092cb 100644 --- a/homeassistant/components/insteon/utils.py +++ b/homeassistant/components/insteon/utils.py @@ -22,7 +22,9 @@ from pyinsteon.managers.x10_manager import ( async_x10_all_units_off, ) from pyinsteon.x10_address import create as create_x10_address +from serial.tools import list_ports +from homeassistant.components import usb from homeassistant.const import ( CONF_ADDRESS, CONF_ENTITY_ID, @@ -393,3 +395,32 @@ def async_add_insteon_entities( for group in groups: new_entities.append(entity_type(device, group)) async_add_entities(new_entities) + + +def get_usb_ports() -> dict[str, str]: + """Return a dict of USB ports and their friendly names.""" + ports = list_ports.comports() + port_descriptions = {} + for port in ports: + vid: str | None = None + pid: str | None = None + if port.vid is not None and port.pid is not None: + usb_device = usb.usb_device_from_port(port) + vid = usb_device.vid + pid = usb_device.pid + dev_path = usb.get_serial_by_id(port.device) + human_name = usb.human_readable_device_name( + dev_path, + port.serial_number, + port.manufacturer, + port.description, + vid, + pid, + ) + port_descriptions[dev_path] = human_name + return port_descriptions + + +async def async_get_usb_ports(hass: HomeAssistant) -> dict[str, str]: + """Return a dict of USB ports and their friendly names.""" + return await hass.async_add_executor_job(get_usb_ports) diff --git a/tests/components/insteon/const.py b/tests/components/insteon/const.py index ec59d94ba72a..eb25f2ed43e6 100644 --- a/tests/components/insteon/const.py +++ b/tests/components/insteon/const.py @@ -96,5 +96,8 @@ MOCK_IMPORT_FULL_CONFIG_HUB_V1[CONF_OVERRIDE] = [MOCK_DEVICE_OVERRIDE_CONFIG] MOCK_IMPORT_FULL_CONFIG_HUB_V1[CONF_X10] = [MOCK_X10_CONFIG_1, MOCK_X10_CONFIG_2] PATCH_CONNECTION = "homeassistant.components.insteon.config_flow.async_connect" +PATCH_CONNECTION_CLOSE = "homeassistant.components.insteon.config_flow.async_close" +PATCH_DEVICES = "homeassistant.components.insteon.config_flow.devices" +PATCH_USB_LIST = "homeassistant.components.insteon.config_flow.async_get_usb_ports" PATCH_ASYNC_SETUP = "homeassistant.components.insteon.async_setup" PATCH_ASYNC_SETUP_ENTRY = "homeassistant.components.insteon.async_setup_entry" diff --git a/tests/components/insteon/test_config_flow.py b/tests/components/insteon/test_config_flow.py index d66d6e07d8cf..70bb8fb37e27 100644 --- a/tests/components/insteon/test_config_flow.py +++ b/tests/components/insteon/test_config_flow.py @@ -2,18 +2,19 @@ from unittest.mock import patch +import pytest +from voluptuous_serialize import convert + from homeassistant import config_entries, data_entry_flow -from homeassistant.components import usb +from homeassistant.components import dhcp, usb from homeassistant.components.insteon.config_flow import ( - HUB1, - HUB2, - MODEM_TYPE, - PLM, STEP_ADD_OVERRIDE, STEP_ADD_X10, STEP_CHANGE_HUB_CONFIG, STEP_CHANGE_PLM_CONFIG, + STEP_HUB_V1, STEP_HUB_V2, + STEP_PLM, STEP_REMOVE_OVERRIDE, STEP_REMOVE_X10, ) @@ -40,6 +41,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from .const import ( + MOCK_DEVICE, MOCK_HOSTNAME, MOCK_IMPORT_CONFIG_PLM, MOCK_IMPORT_MINIMUM_HUB_V1, @@ -52,16 +54,37 @@ from .const import ( PATCH_ASYNC_SETUP, PATCH_ASYNC_SETUP_ENTRY, PATCH_CONNECTION, + PATCH_CONNECTION_CLOSE, + PATCH_DEVICES, + PATCH_USB_LIST, ) +from .mock_devices import MockDevices from tests.common import MockConfigEntry +USB_PORTS = {"/dev/ttyUSB0": "/dev/ttyUSB0", MOCK_DEVICE: MOCK_DEVICE} + async def mock_successful_connection(*args, **kwargs): """Return a successful connection.""" return True +async def mock_usb_list(hass: HomeAssistant): + """Return a mock list of USB devices.""" + return USB_PORTS + + +@pytest.fixture(autouse=True) +def patch_usb_list(): + """Only setup the lock and required base platforms to speed up tests.""" + with patch( + PATCH_USB_LIST, + mock_usb_list, + ): + yield + + async def mock_failed_connection(*args, **kwargs): """Return a failed connection.""" raise ConnectionError("Connection failed") @@ -72,12 +95,11 @@ async def _init_form(hass, modem_type): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["errors"] == {} + assert result["type"] == data_entry_flow.FlowResultType.MENU result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {MODEM_TYPE: modem_type}, + {"next_step_id": modem_type}, ) return result2 @@ -99,7 +121,7 @@ async def _device_form(hass, flow_id, connection, user_input): async def test_form_select_modem(hass: HomeAssistant) -> None: """Test we get a modem form.""" - result = await _init_form(hass, HUB2) + result = await _init_form(hass, STEP_HUB_V2) assert result["step_id"] == STEP_HUB_V2 assert result["type"] == "form" @@ -127,7 +149,7 @@ async def test_fail_on_existing(hass: HomeAssistant) -> None: async def test_form_select_plm(hass: HomeAssistant) -> None: """Test we set up the PLM correctly.""" - result = await _init_form(hass, PLM) + result = await _init_form(hass, STEP_PLM) result2, mock_setup, mock_setup_entry = await _device_form( hass, result["flow_id"], mock_successful_connection, MOCK_USER_INPUT_PLM @@ -142,7 +164,7 @@ async def test_form_select_plm(hass: HomeAssistant) -> None: async def test_form_select_hub_v1(hass: HomeAssistant) -> None: """Test we set up the Hub v1 correctly.""" - result = await _init_form(hass, HUB1) + result = await _init_form(hass, STEP_HUB_V1) result2, mock_setup, mock_setup_entry = await _device_form( hass, result["flow_id"], mock_successful_connection, MOCK_USER_INPUT_HUB_V1 @@ -160,7 +182,7 @@ async def test_form_select_hub_v1(hass: HomeAssistant) -> None: async def test_form_select_hub_v2(hass: HomeAssistant) -> None: """Test we set up the Hub v2 correctly.""" - result = await _init_form(hass, HUB2) + result = await _init_form(hass, STEP_HUB_V2) result2, mock_setup, mock_setup_entry = await _device_form( hass, result["flow_id"], mock_successful_connection, MOCK_USER_INPUT_HUB_V2 @@ -175,10 +197,32 @@ async def test_form_select_hub_v2(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +async def test_form_discovery_dhcp(hass: HomeAssistant) -> None: + """Test the discovery of the Hub via DHCP.""" + discovery_info = dhcp.DhcpServiceInfo("1.2.3.4", "", "aa:bb:cc:dd:ee:ff") + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=discovery_info + ) + assert result["type"] == data_entry_flow.FlowResultType.MENU + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": STEP_HUB_V2}, + ) + assert result2["type"] == data_entry_flow.FlowResultType.FORM + schema = convert(result2["data_schema"]) + found_host = False + for field in schema: + if field["name"] == CONF_HOST: + assert field["default"] == "1.2.3.4" + found_host = True + assert found_host + + async def test_failed_connection_plm(hass: HomeAssistant) -> None: """Test a failed connection with the PLM.""" - result = await _init_form(hass, PLM) + result = await _init_form(hass, STEP_PLM) result2, _, _ = await _device_form( hass, result["flow_id"], mock_failed_connection, MOCK_USER_INPUT_PLM @@ -190,7 +234,7 @@ async def test_failed_connection_plm(hass: HomeAssistant) -> None: async def test_failed_connection_hub(hass: HomeAssistant) -> None: """Test a failed connection with a Hub.""" - result = await _init_form(hass, HUB2) + result = await _init_form(hass, STEP_HUB_V2) result2, _, _ = await _device_form( hass, result["flow_id"], mock_failed_connection, MOCK_USER_INPUT_HUB_V2 @@ -228,12 +272,12 @@ async def _options_init_form(hass, entry_id, step): with patch(PATCH_ASYNC_SETUP_ENTRY, return_value=True): result = await hass.config_entries.options.async_init(entry_id) - assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["type"] == data_entry_flow.FlowResultType.MENU assert result["step_id"] == "init" result2 = await hass.config_entries.options.async_configure( result["flow_id"], - {step: True}, + {"next_step_id": step}, ) return result2 @@ -307,10 +351,18 @@ async def test_import_failed_connection(hass: HomeAssistant) -> None: assert result["reason"] == "cannot_connect" -async def _options_form(hass, flow_id, user_input): +async def _options_form( + hass, flow_id, user_input, connection=mock_successful_connection +): """Test an options form.""" - - with patch(PATCH_ASYNC_SETUP_ENTRY, return_value=True) as mock_setup_entry: + mock_devices = MockDevices(connected=True) + await mock_devices.async_load() + mock_devices.modem = mock_devices["AA.AA.AA"] + with patch(PATCH_CONNECTION, new=connection), patch( + PATCH_ASYNC_SETUP_ENTRY, return_value=True + ) as mock_setup_entry, patch(PATCH_DEVICES, mock_devices), patch( + PATCH_CONNECTION_CLOSE + ): result = await hass.config_entries.options.async_configure(flow_id, user_input) return result, mock_setup_entry @@ -336,12 +388,39 @@ async def test_options_change_hub_config(hass: HomeAssistant) -> None: CONF_PASSWORD: "new password", } result, _ = await _options_form(hass, result["flow_id"], user_input) - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert config_entry.options == {} assert config_entry.data == {**user_input, CONF_HUB_VERSION: 2} +async def test_options_change_hub_bad_config(hass: HomeAssistant) -> None: + """Test changing Hub v2 with bad config.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + entry_id="abcde12345", + data={**MOCK_USER_INPUT_HUB_V2, CONF_HUB_VERSION: 2}, + options={}, + ) + + config_entry.add_to_hass(hass) + result = await _options_init_form( + hass, config_entry.entry_id, STEP_CHANGE_HUB_CONFIG + ) + + user_input = { + CONF_HOST: "2.3.4.5", + CONF_PORT: 9999, + CONF_USERNAME: "new username", + CONF_PASSWORD: "new password", + } + result, _ = await _options_form( + hass, result["flow_id"], user_input, mock_failed_connection + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["errors"]["base"] == "cannot_connect" + + async def test_options_change_plm_config(hass: HomeAssistant) -> None: """Test changing PLM config.""" config_entry = MockConfigEntry( @@ -356,7 +435,7 @@ async def test_options_change_plm_config(hass: HomeAssistant) -> None: hass, config_entry.entry_id, STEP_CHANGE_PLM_CONFIG ) - user_input = {CONF_DEVICE: "/dev/some_other_device"} + user_input = {CONF_DEVICE: "/dev/ttyUSB0"} result, _ = await _options_form(hass, result["flow_id"], user_input) assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY @@ -364,6 +443,31 @@ async def test_options_change_plm_config(hass: HomeAssistant) -> None: assert config_entry.data == user_input +async def test_options_change_plm_bad_config(hass: HomeAssistant) -> None: + """Test changing PLM config.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + entry_id="abcde12345", + data=MOCK_USER_INPUT_PLM, + options={}, + ) + + config_entry.add_to_hass(hass) + result = await _options_init_form( + hass, config_entry.entry_id, STEP_CHANGE_PLM_CONFIG + ) + + user_input = {CONF_DEVICE: "/dev/ttyUSB0"} + result, _ = await _options_form( + hass, result["flow_id"], user_input, mock_failed_connection + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["errors"]["base"] == "cannot_connect" + + async def test_options_add_device_override(hass: HomeAssistant) -> None: """Test adding a device override.""" config_entry = MockConfigEntry( @@ -581,28 +685,6 @@ async def test_options_remove_x10_device_with_override(hass: HomeAssistant) -> N assert len(config_entry.options[CONF_OVERRIDE]) == 1 -async def test_options_dup_selection(hass: HomeAssistant) -> None: - """Test if a duplicate selection was made in options.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - entry_id="abcde12345", - data={**MOCK_USER_INPUT_HUB_V2, CONF_HUB_VERSION: 2}, - options={}, - ) - config_entry.add_to_hass(hass) - result = await hass.config_entries.options.async_init(config_entry.entry_id) - - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "init" - - result2 = await hass.config_entries.options.async_configure( - result["flow_id"], - {STEP_ADD_OVERRIDE: True, STEP_ADD_X10: True}, - ) - assert result2["type"] == data_entry_flow.FlowResultType.FORM - assert result2["errors"] == {"base": "select_single"} - - async def test_options_override_bad_data(hass: HomeAssistant) -> None: """Test for bad data in a device override.""" @@ -644,9 +726,7 @@ async def test_discovery_via_usb(hass: HomeAssistant) -> None: assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["step_id"] == "confirm_usb" - with patch("homeassistant.components.insteon.config_flow.async_connect"), patch( - "homeassistant.components.insteon.async_setup_entry", return_value=True - ): + with patch(PATCH_CONNECTION), patch(PATCH_ASYNC_SETUP, return_value=True): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) From fdf40fd1e554e74338ad090b39a4fc9462c57dbc Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Tue, 23 May 2023 10:55:55 -0600 Subject: [PATCH 470/822] Remove @bachya as a Slack codeowner (#93408) * Remove @bachya as a Slack codeowner * Manifest --- CODEOWNERS | 4 ++-- homeassistant/components/slack/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 406fa6e4f3cf..1c0efd926097 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1103,8 +1103,8 @@ build.json @home-assistant/supervisor /homeassistant/components/sky_hub/ @rogerselwyn /homeassistant/components/skybell/ @tkdrob /tests/components/skybell/ @tkdrob -/homeassistant/components/slack/ @bachya @tkdrob -/tests/components/slack/ @bachya @tkdrob +/homeassistant/components/slack/ @tkdrob +/tests/components/slack/ @tkdrob /homeassistant/components/sleepiq/ @mfugate1 @kbickar /tests/components/sleepiq/ @mfugate1 @kbickar /homeassistant/components/slide/ @ualex73 diff --git a/homeassistant/components/slack/manifest.json b/homeassistant/components/slack/manifest.json index 92816e94bf6f..2bd3476cbbe4 100644 --- a/homeassistant/components/slack/manifest.json +++ b/homeassistant/components/slack/manifest.json @@ -1,7 +1,7 @@ { "domain": "slack", "name": "Slack", - "codeowners": ["@bachya", "@tkdrob"], + "codeowners": ["@tkdrob"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/slack", "integration_type": "service", From 761943e1e67a34d3395f6f095caa1b9b912fb8b1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 May 2023 11:56:27 -0500 Subject: [PATCH 471/822] Fix non threadsafe call xiaomi_aqara (#93405) --- homeassistant/components/xiaomi_aqara/__init__.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/xiaomi_aqara/__init__.py b/homeassistant/components/xiaomi_aqara/__init__.py index be6eba6793ee..f51b1a2972ba 100644 --- a/homeassistant/components/xiaomi_aqara/__init__.py +++ b/homeassistant/components/xiaomi_aqara/__init__.py @@ -2,6 +2,7 @@ import asyncio from datetime import timedelta import logging +from typing import Any import voluptuous as vol from xiaomi_gateway import AsyncXiaomiGatewayMulticast, XiaomiGateway @@ -351,9 +352,13 @@ class XiaomiDevice(Entity): return True return False + def push_data(self, data: dict[str, Any], raw_data: dict[Any, Any]) -> None: + """Push from Hub running in another thread.""" + self.hass.loop.call_soon(self.async_push_data, data, raw_data) + @callback - def push_data(self, data, raw_data): - """Push from Hub.""" + def async_push_data(self, data: dict[str, Any], raw_data: dict[Any, Any]) -> None: + """Push from Hub handled in the event loop.""" _LOGGER.debug("PUSH >> %s: %s", self, data) was_unavailable = self._async_track_unavailable() is_data = self.parse_data(data, raw_data) From 9672db0354c6b612fa84c730aca2d69390f01105 Mon Sep 17 00:00:00 2001 From: chiefdragon <11260692+chiefdragon@users.noreply.github.com> Date: Tue, 23 May 2023 18:08:00 +0100 Subject: [PATCH 472/822] Add new preset to Tado to enable geofencing mode (#92877) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add new preset to Tado to enable geofencing mode Add new 'auto' preset mode to enable Tado to be set to auto geofencing mode. The existing ‘home’ and ‘away’ presets switched Tado into manual geofencing mode and there was no way to restore it to auto mode. Note 1: Since preset modes (home, away and auto) apply to the Tado home holistically, irrespective of the Tado climate entity used to select the preset, three new sensors have been added to display the state of the Tado home Note 2: Auto mode is only supported if the Auto Assist skill is enabled in the owner's Tado home. Various checks have been added to ensure the Tado supports auto geofencing and if it is not supported, the preset is not listed in the preset modes available * Update codeowners in manifest.json * Update main codeowners file for Tado component --- CODEOWNERS | 4 +- homeassistant/components/tado/__init__.py | 34 +++++++-- homeassistant/components/tado/climate.py | 41 ++++++++-- homeassistant/components/tado/const.py | 8 +- homeassistant/components/tado/manifest.json | 4 +- homeassistant/components/tado/sensor.py | 74 +++++++++++++++++-- homeassistant/components/tado/strings.json | 13 ++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../components/tado/fixtures/home_state.json | 4 + tests/components/tado/test_climate.py | 12 +-- tests/components/tado/util.py | 5 ++ 12 files changed, 174 insertions(+), 29 deletions(-) create mode 100644 tests/components/tado/fixtures/home_state.json diff --git a/CODEOWNERS b/CODEOWNERS index 1c0efd926097..b3ae7e942150 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1209,8 +1209,8 @@ build.json @home-assistant/supervisor /homeassistant/components/synology_srm/ @aerialls /homeassistant/components/system_bridge/ @timmo001 /tests/components/system_bridge/ @timmo001 -/homeassistant/components/tado/ @michaelarnauts -/tests/components/tado/ @michaelarnauts +/homeassistant/components/tado/ @michaelarnauts @chiefdragon +/tests/components/tado/ @michaelarnauts @chiefdragon /homeassistant/components/tag/ @balloob @dmulcahey /tests/components/tag/ @balloob @dmulcahey /homeassistant/components/tailscale/ @frenck diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py index 691ca639656c..1cd21634c8e1 100644 --- a/homeassistant/components/tado/__init__.py +++ b/homeassistant/components/tado/__init__.py @@ -25,6 +25,7 @@ from .const import ( DATA, DOMAIN, INSIDE_TEMPERATURE_MEASUREMENT, + PRESET_AUTO, SIGNAL_TADO_UPDATE_RECEIVED, TEMP_OFFSET, UPDATE_LISTENER, @@ -151,6 +152,7 @@ class TadoConnector: self.data = { "device": {}, "weather": {}, + "geofence": {}, "zone": {}, } @@ -175,11 +177,7 @@ class TadoConnector: """Update the registered zones.""" self.update_devices() self.update_zones() - self.data["weather"] = self.tado.getWeather() - dispatcher_send( - self.hass, - SIGNAL_TADO_UPDATE_RECEIVED.format(self.home_id, "weather", "data"), - ) + self.update_home() def update_devices(self): """Update the device data from Tado.""" @@ -250,10 +248,29 @@ class TadoConnector: SIGNAL_TADO_UPDATE_RECEIVED.format(self.home_id, "zone", zone_id), ) + def update_home(self): + """Update the home data from Tado.""" + try: + self.data["weather"] = self.tado.getWeather() + self.data["geofence"] = self.tado.getHomeState() + dispatcher_send( + self.hass, + SIGNAL_TADO_UPDATE_RECEIVED.format(self.home_id, "home", "data"), + ) + except RuntimeError: + _LOGGER.error( + "Unable to connect to Tado while updating weather and geofence data" + ) + return + def get_capabilities(self, zone_id): """Return the capabilities of the devices.""" return self.tado.getCapabilities(zone_id) + def get_auto_geofencing_supported(self): + """Return whether the Tado Home supports auto geofencing.""" + return self.tado.getAutoGeofencingSupported() + def reset_zone_overlay(self, zone_id): """Reset the zone back to the default operation.""" self.tado.resetZoneOverlay(zone_id) @@ -263,12 +280,17 @@ class TadoConnector: self, presence=PRESET_HOME, ): - """Set the presence to home or away.""" + """Set the presence to home, away or auto.""" if presence == PRESET_AWAY: self.tado.setAway() elif presence == PRESET_HOME: self.tado.setHome() + elif presence == PRESET_AUTO: + self.tado.setAuto() + + # Update everything when changing modes self.update_zones() + self.update_home() def set_zone_overlay( self, diff --git a/homeassistant/components/tado/climate.py b/homeassistant/components/tado/climate.py index cab3c42184e0..2b8bc4060d67 100644 --- a/homeassistant/components/tado/climate.py +++ b/homeassistant/components/tado/climate.py @@ -44,8 +44,10 @@ from .const import ( HA_TO_TADO_HVAC_MODE_MAP, HA_TO_TADO_SWING_MODE_MAP, ORDERED_KNOWN_TADO_MODES, + PRESET_AUTO, SIGNAL_TADO_UPDATE_RECEIVED, - SUPPORT_PRESET, + SUPPORT_PRESET_AUTO, + SUPPORT_PRESET_MANUAL, TADO_HVAC_ACTION_TO_HA_HVAC_ACTION, TADO_MODES_WITH_NO_TEMP_SETTING, TADO_SWING_OFF, @@ -245,6 +247,8 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): self._attr_name = zone_name self._attr_temperature_unit = UnitOfTemperature.CELSIUS + self._attr_translation_key = DOMAIN + self._device_info = device_info self._device_id = self._device_info["shortSerialNo"] @@ -274,21 +278,31 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): self._current_tado_swing_mode = TADO_SWING_OFF self._tado_zone_data = None + self._tado_geofence_data = None self._tado_zone_temp_offset = {} + self._async_update_home_data() self._async_update_zone_data() async def async_added_to_hass(self) -> None: """Register for sensor updates.""" + self.async_on_remove( + async_dispatcher_connect( + self.hass, + SIGNAL_TADO_UPDATE_RECEIVED.format(self._tado.home_id, "home", "data"), + self._async_update_home_callback, + ) + ) + self.async_on_remove( async_dispatcher_connect( self.hass, SIGNAL_TADO_UPDATE_RECEIVED.format( self._tado.home_id, "zone", self.zone_id ), - self._async_update_callback, + self._async_update_zone_callback, ) ) @@ -346,7 +360,11 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): @property def preset_mode(self): - """Return the current preset mode (home, away).""" + """Return the current preset mode (home, away or auto).""" + + if "presenceLocked" in self._tado_geofence_data: + if not self._tado_geofence_data["presenceLocked"]: + return PRESET_AUTO if self._tado_zone_data.is_away: return PRESET_AWAY return PRESET_HOME @@ -354,7 +372,9 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): @property def preset_modes(self): """Return a list of available preset modes.""" - return SUPPORT_PRESET + if self._tado.get_auto_geofencing_supported(): + return SUPPORT_PRESET_AUTO + return SUPPORT_PRESET_MANUAL def set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" @@ -501,11 +521,22 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): self._current_tado_swing_mode = self._tado_zone_data.current_swing_mode @callback - def _async_update_callback(self): + def _async_update_zone_callback(self): """Load tado data and update state.""" self._async_update_zone_data() self.async_write_ha_state() + @callback + def _async_update_home_data(self): + """Load tado geofencing data into zone.""" + self._tado_geofence_data = self._tado.data["geofence"] + + @callback + def _async_update_home_callback(self): + """Load tado data and update state.""" + self._async_update_home_data() + self.async_write_ha_state() + def _normalize_target_temp_for_hvac_mode(self): # Set a target temperature if we don't have any # This can happen when we switch from Off to On diff --git a/homeassistant/components/tado/const.py b/homeassistant/components/tado/const.py index 94d074c40662..9366a18b6fef 100644 --- a/homeassistant/components/tado/const.py +++ b/homeassistant/components/tado/const.py @@ -153,8 +153,14 @@ TADO_TO_HA_FAN_MODE_MAP = {value: key for key, value in HA_TO_TADO_FAN_MODE_MAP. DEFAULT_TADO_PRECISION = 0.1 -SUPPORT_PRESET = [PRESET_AWAY, PRESET_HOME] +# Constant for Auto Geolocation mode +PRESET_AUTO = "auto" +SUPPORT_PRESET_AUTO = [PRESET_AWAY, PRESET_HOME, PRESET_AUTO] +SUPPORT_PRESET_MANUAL = [PRESET_AWAY, PRESET_HOME] + +SENSOR_DATA_CATEGORY_WEATHER = "weather" +SENSOR_DATA_CATEGORY_GEOFENCE = "geofence" TADO_SWING_OFF = "OFF" TADO_SWING_ON = "ON" diff --git a/homeassistant/components/tado/manifest.json b/homeassistant/components/tado/manifest.json index 86ace76c8492..62f7a377239e 100644 --- a/homeassistant/components/tado/manifest.json +++ b/homeassistant/components/tado/manifest.json @@ -1,7 +1,7 @@ { "domain": "tado", "name": "Tado", - "codeowners": ["@michaelarnauts"], + "codeowners": ["@michaelarnauts", "@chiefdragon"], "config_flow": true, "dhcp": [ { @@ -14,5 +14,5 @@ }, "iot_class": "cloud_polling", "loggers": ["PyTado"], - "requirements": ["python-tado==0.12.0"] + "requirements": ["python-tado==0.15.0"] } diff --git a/homeassistant/components/tado/sensor.py b/homeassistant/components/tado/sensor.py index d218e9ca9337..7742f6b0dca3 100644 --- a/homeassistant/components/tado/sensor.py +++ b/homeassistant/components/tado/sensor.py @@ -23,6 +23,8 @@ from .const import ( CONDITIONS_MAP, DATA, DOMAIN, + SENSOR_DATA_CATEGORY_GEOFENCE, + SENSOR_DATA_CATEGORY_WEATHER, SIGNAL_TADO_UPDATE_RECEIVED, TYPE_AIR_CONDITIONING, TYPE_HEATING, @@ -47,6 +49,7 @@ class TadoSensorEntityDescription( """Describes Tado sensor entity.""" attributes_fn: Callable[[Any], dict[Any, StateType]] | None = None + data_category: str | None = None HOME_SENSORS = [ @@ -60,6 +63,7 @@ HOME_SENSORS = [ native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, + data_category=SENSOR_DATA_CATEGORY_WEATHER, ), TadoSensorEntityDescription( key="solar percentage", @@ -70,12 +74,35 @@ HOME_SENSORS = [ }, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, + data_category=SENSOR_DATA_CATEGORY_WEATHER, ), TadoSensorEntityDescription( key="weather condition", name="Weather condition", state_fn=lambda data: format_condition(data["weatherState"]["value"]), attributes_fn=lambda data: {"time": data["weatherState"]["timestamp"]}, + data_category=SENSOR_DATA_CATEGORY_WEATHER, + ), + TadoSensorEntityDescription( + key="tado mode", + name="Tado mode", + # pylint: disable=unnecessary-lambda + state_fn=lambda data: get_tado_mode(data), + data_category=SENSOR_DATA_CATEGORY_GEOFENCE, + ), + TadoSensorEntityDescription( + key="geofencing mode", + name="Geofencing mode", + # pylint: disable=unnecessary-lambda + state_fn=lambda data: get_geofencing_mode(data), + data_category=SENSOR_DATA_CATEGORY_GEOFENCE, + ), + TadoSensorEntityDescription( + key="automatic geofencing", + name="Automatic geofencing", + # pylint: disable=unnecessary-lambda + state_fn=lambda data: get_automatic_geofencing(data), + data_category=SENSOR_DATA_CATEGORY_GEOFENCE, ), ] @@ -145,6 +172,39 @@ def format_condition(condition: str) -> str: 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: @@ -200,9 +260,7 @@ class TadoHomeSensor(TadoHomeEntity, SensorEntity): self.async_on_remove( async_dispatcher_connect( self.hass, - SIGNAL_TADO_UPDATE_RECEIVED.format( - self._tado.home_id, "weather", "data" - ), + SIGNAL_TADO_UPDATE_RECEIVED.format(self._tado.home_id, "home", "data"), self._async_update_callback, ) ) @@ -219,13 +277,19 @@ class TadoHomeSensor(TadoHomeEntity, SensorEntity): """Handle update callbacks.""" try: tado_weather_data = self._tado.data["weather"] + tado_geofence_data = self._tado.data["geofence"] except KeyError: return - self._attr_native_value = self.entity_description.state_fn(tado_weather_data) + if self.entity_description.data_category is not None: + if self.entity_description.data_category == SENSOR_DATA_CATEGORY_WEATHER: + tado_sensor_data = tado_weather_data + else: + tado_sensor_data = tado_geofence_data + self._attr_native_value = self.entity_description.state_fn(tado_sensor_data) if self.entity_description.attributes_fn is not None: self._attr_extra_state_attributes = self.entity_description.attributes_fn( - tado_weather_data + tado_sensor_data ) diff --git a/homeassistant/components/tado/strings.json b/homeassistant/components/tado/strings.json index e1bf1a1406d6..3decfe3cd0c9 100644 --- a/homeassistant/components/tado/strings.json +++ b/homeassistant/components/tado/strings.json @@ -29,5 +29,18 @@ "title": "Adjust Tado options." } } + }, + "entity": { + "climate": { + "tado": { + "state_attributes": { + "preset_mode": { + "state": { + "auto": "Auto" + } + } + } + } + } } } diff --git a/requirements_all.txt b/requirements_all.txt index 1c7e3e96618b..ef7c225d8c41 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2123,7 +2123,7 @@ python-smarttub==0.0.33 python-songpal==0.15.2 # homeassistant.components.tado -python-tado==0.12.0 +python-tado==0.15.0 # homeassistant.components.telegram_bot python-telegram-bot==13.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cf4f88351a32..689c731a057a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1543,7 +1543,7 @@ python-smarttub==0.0.33 python-songpal==0.15.2 # homeassistant.components.tado -python-tado==0.12.0 +python-tado==0.15.0 # homeassistant.components.telegram_bot python-telegram-bot==13.1 diff --git a/tests/components/tado/fixtures/home_state.json b/tests/components/tado/fixtures/home_state.json new file mode 100644 index 000000000000..dc073fbfd78e --- /dev/null +++ b/tests/components/tado/fixtures/home_state.json @@ -0,0 +1,4 @@ +{ + "presence": "HOME", + "presenceLocked": false +} diff --git a/tests/components/tado/test_climate.py b/tests/components/tado/test_climate.py index 35e017278aa6..fd4ae87ac649 100644 --- a/tests/components/tado/test_climate.py +++ b/tests/components/tado/test_climate.py @@ -22,8 +22,8 @@ async def test_air_con(hass: HomeAssistant) -> None: "hvac_modes": ["off", "auto", "heat", "cool", "heat_cool", "dry", "fan_only"], "max_temp": 31.0, "min_temp": 16.0, - "preset_mode": "home", - "preset_modes": ["away", "home"], + "preset_mode": "auto", + "preset_modes": ["away", "home", "auto"], "supported_features": 25, "target_temp_step": 1, "temperature": 17.8, @@ -49,8 +49,8 @@ async def test_heater(hass: HomeAssistant) -> None: "hvac_modes": ["off", "auto", "heat"], "max_temp": 31.0, "min_temp": 16.0, - "preset_mode": "home", - "preset_modes": ["away", "home"], + "preset_mode": "auto", + "preset_modes": ["away", "home", "auto"], "supported_features": 17, "target_temp_step": 1, "temperature": 20.5, @@ -78,8 +78,8 @@ async def test_smartac_with_swing(hass: HomeAssistant) -> None: "hvac_modes": ["off", "auto", "heat", "cool", "heat_cool", "dry", "fan_only"], "max_temp": 30.0, "min_temp": 16.0, - "preset_mode": "home", - "preset_modes": ["away", "home"], + "preset_mode": "auto", + "preset_modes": ["away", "home", "auto"], "swing_modes": ["on", "off"], "supported_features": 57, "target_temp_step": 1.0, diff --git a/tests/components/tado/util.py b/tests/components/tado/util.py index 899d2ce1f21d..21e0e255ed11 100644 --- a/tests/components/tado/util.py +++ b/tests/components/tado/util.py @@ -19,6 +19,7 @@ async def async_init_integration( devices_fixture = "tado/devices.json" me_fixture = "tado/me.json" weather_fixture = "tado/weather.json" + home_state_fixture = "tado/home_state.json" zones_fixture = "tado/zones.json" zone_states_fixture = "tado/zone_states.json" @@ -61,6 +62,10 @@ async def async_init_integration( "https://my.tado.com/api/v2/homes/1/weather", text=load_fixture(weather_fixture), ) + m.get( + "https://my.tado.com/api/v2/homes/1/state", + text=load_fixture(home_state_fixture), + ) m.get( "https://my.tado.com/api/v2/homes/1/devices", text=load_fixture(devices_fixture), From 942089d590e076d670b01a858f08ce63e182786f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Tue, 23 May 2023 19:10:33 +0200 Subject: [PATCH 473/822] Add QNAP QSW firmware update support (#92869) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * qnap_qsw: add firmware update support Signed-off-by: Álvaro Fernández Rojas * tests: qnap_qsw: remove backup leftovers Signed-off-by: Álvaro Fernández Rojas * tests: qnap_qsw: remove backup leftovers v2 Signed-off-by: Álvaro Fernández Rojas --------- Signed-off-by: Álvaro Fernández Rojas --- homeassistant/components/qnap_qsw/update.py | 14 +++- tests/components/qnap_qsw/test_update.py | 71 +++++++++++++++++++-- tests/components/qnap_qsw/util.py | 16 ++--- 3 files changed, 86 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/qnap_qsw/update.py b/homeassistant/components/qnap_qsw/update.py index 70bd4afa880a..38a963818d49 100644 --- a/homeassistant/components/qnap_qsw/update.py +++ b/homeassistant/components/qnap_qsw/update.py @@ -1,7 +1,7 @@ """Support for the QNAP QSW update.""" from __future__ import annotations -from typing import Final +from typing import Any, Final from aioqsw.const import ( QSD_DESCRIPTION, @@ -16,6 +16,7 @@ from homeassistant.components.update import ( UpdateDeviceClass, UpdateEntity, UpdateEntityDescription, + UpdateEntityFeature, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory @@ -51,6 +52,7 @@ async def async_setup_entry( class QswUpdate(QswFirmwareEntity, UpdateEntity): """Define a QNAP QSW update.""" + _attr_supported_features = UpdateEntityFeature.INSTALL entity_description: UpdateEntityDescription def __init__( @@ -87,3 +89,13 @@ class QswUpdate(QswFirmwareEntity, UpdateEntity): self._attr_release_summary = self.get_device_value( QSD_FIRMWARE_CHECK, QSD_DESCRIPTION ) + + async def async_install( + self, version: str | None, backup: bool, **kwargs: Any + ) -> None: + """Install an update.""" + await self.coordinator.async_refresh() + await self.coordinator.qsw.live_update() + + self._attr_installed_version = self.latest_version + self.async_write_ha_state() diff --git a/tests/components/qnap_qsw/test_update.py b/tests/components/qnap_qsw/test_update.py index 69f4a3d08b4d..26b7157f64da 100644 --- a/tests/components/qnap_qsw/test_update.py +++ b/tests/components/qnap_qsw/test_update.py @@ -1,11 +1,32 @@ """The sensor tests for the QNAP QSW platform.""" -from aioqsw.const import API_RESULT, API_VERSION +from unittest.mock import patch -from homeassistant.const import STATE_OFF +from aioqsw.const import API_ERROR_CODE, API_ERROR_MESSAGE, API_RESULT, API_VERSION + +from homeassistant.components.update import ( + ATTR_BACKUP, + ATTR_IN_PROGRESS, + ATTR_INSTALLED_VERSION, + ATTR_LATEST_VERSION, + DOMAIN as UPDATE_DOMAIN, + SERVICE_INSTALL, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from .util import FIRMWARE_INFO_MOCK, FIRMWARE_UPDATE_CHECK_MOCK, async_init_integration +from .util import ( + FIRMWARE_INFO_MOCK, + FIRMWARE_UPDATE_CHECK_MOCK, + USERS_VERIFICATION_MOCK, + async_init_integration, +) + +FIRMWARE_UPDATE_LIVE_MOCK = { + API_ERROR_CODE: 200, + API_ERROR_MESSAGE: "OK", + API_RESULT: "None", +} async def test_qnap_qsw_update(hass: HomeAssistant) -> None: @@ -15,12 +36,50 @@ async def test_qnap_qsw_update(hass: HomeAssistant) -> None: update = hass.states.get("update.qsw_m408_4c_firmware_update") assert update is not None - assert update.state == STATE_OFF + assert update.state == STATE_ON assert ( - update.attributes.get("installed_version") + update.attributes[ATTR_INSTALLED_VERSION] == FIRMWARE_INFO_MOCK[API_RESULT][API_VERSION] ) assert ( - update.attributes.get("latest_version") + update.attributes[ATTR_LATEST_VERSION] == FIRMWARE_UPDATE_CHECK_MOCK[API_RESULT][API_VERSION] ) + assert update.attributes[ATTR_IN_PROGRESS] is False + + with patch( + "homeassistant.components.qnap_qsw.QnapQswApi.get_firmware_update_check", + return_value=FIRMWARE_UPDATE_CHECK_MOCK, + ) as mock_firmware_update_check, patch( + "homeassistant.components.qnap_qsw.QnapQswApi.get_users_verification", + return_value=USERS_VERIFICATION_MOCK, + ) as mock_users_verification, patch( + "homeassistant.components.qnap_qsw.QnapQswApi.post_firmware_update_live", + return_value=FIRMWARE_UPDATE_LIVE_MOCK, + ) as mock_firmware_update_live: + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + { + ATTR_BACKUP: False, + ATTR_ENTITY_ID: "update.qsw_m408_4c_firmware_update", + }, + blocking=True, + ) + + mock_firmware_update_check.assert_called_once() + mock_firmware_update_live.assert_called_once() + mock_users_verification.assert_called() + + update = hass.states.get("update.qsw_m408_4c_firmware_update") + assert update is not None + assert update.state == STATE_OFF + assert ( + update.attributes[ATTR_INSTALLED_VERSION] + == FIRMWARE_UPDATE_CHECK_MOCK[API_RESULT][API_VERSION] + ) + assert ( + update.attributes[ATTR_LATEST_VERSION] + == FIRMWARE_UPDATE_CHECK_MOCK[API_RESULT][API_VERSION] + ) + assert update.attributes[ATTR_IN_PROGRESS] is False diff --git a/tests/components/qnap_qsw/util.py b/tests/components/qnap_qsw/util.py index 5ae801283bc5..b0dd5d5bf60f 100644 --- a/tests/components/qnap_qsw/util.py +++ b/tests/components/qnap_qsw/util.py @@ -109,17 +109,17 @@ FIRMWARE_UPDATE_CHECK_MOCK = { API_ERROR_CODE: 200, API_ERROR_MESSAGE: "OK", API_RESULT: { - API_VERSION: "1.2.0", - API_NUMBER: "29649", - API_BUILD_NUMBER: "20220128", - API_DATE: "Fri, 28 Jan 2022 01:17:39 +0800", + API_VERSION: "1.3.0", + API_NUMBER: "527638", + API_BUILD_NUMBER: "20221123", + API_DATE: "Wed, 07, Dec 2022 16:30:00 +0800", API_DESCRIPTION: "", API_DOWNLOAD_URL: [ - "https://download.qnap.com/Storage/Networking/QSW408FW/QSW-M408AC3-FW.v1.2.0_S20220128_29649.img", - "https://eu1.qnap.com/Storage/Networking/QSW408FW/QSW-M408AC3-FW.v1.2.0_S20220128_29649.img", - "https://us1.qnap.com/Storage/Networking/QSW408FW/QSW-M408AC3-FW.v1.2.0_S20220128_29649.img", + "https://download.qnap.com/Storage/Networking/QSW408FW/QSW-M408AC3-FW.v1.3.0_S20221123_527638.img", + "https://eu1.qnap.com/Storage/Networking/QSW408FW/QSW-M408AC3-FW.v1.3.0_S20221123_527638.img", + "https://us1.qnap.com/Storage/Networking/QSW408FW/QSW-M408AC3-FW.v1.3.0_S20221123_527638.img", ], - API_NEWER: False, + API_NEWER: True, }, } From 8bf22014cebe9018ad3b04652544e8769a022cd4 Mon Sep 17 00:00:00 2001 From: Michael Mraka Date: Tue, 23 May 2023 19:20:04 +0200 Subject: [PATCH 474/822] Update solax state class for sensors with no units (#92914) Update sensor.py Units.NONE is used for text entities which are not measurements. Marking them so breaks their history. --- homeassistant/components/solax/sensor.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/solax/sensor.py b/homeassistant/components/solax/sensor.py index 2a923c3b7252..fd0db1be054f 100644 --- a/homeassistant/components/solax/sensor.py +++ b/homeassistant/components/solax/sensor.py @@ -87,7 +87,6 @@ SENSOR_DESCRIPTIONS: dict[tuple[Units, bool], SensorEntityDescription] = { ), (Units.NONE, False): SensorEntityDescription( key=f"{Units.NONE}_{False}", - state_class=SensorStateClass.MEASUREMENT, ), } From 8edb253ace430189d9aeb37fd46480561a723899 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Tue, 23 May 2023 19:22:50 +0200 Subject: [PATCH 475/822] Add Airzone Cloud integration (#93238) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * airzone-cloud: add new integration Signed-off-by: Álvaro Fernández Rojas * Add missing aioairzone-cloud to test requirements Signed-off-by: Álvaro Fernández Rojas * Update aioairzone-cloud to v0.0.4 Allows to handle TooManyRequests exception on coordinator. Signed-off-by: Álvaro Fernández Rojas * aioairzone_cloud: reduce API requests Signed-off-by: Álvaro Fernández Rojas * airzone_cloud: remove system_zone_id As opposed to the Local API of Airzone devices, the Cloud API provides unique IDs for both systems and zones, so we can remove the system_zone_id copied from the Local API integration. Signed-off-by: Álvaro Fernández Rojas * airzone_cloud: minor improvements Signed-off-by: Álvaro Fernández Rojas * tests: airzone_cloud: simplify mock_get_webserver Signed-off-by: Álvaro Fernández Rojas * Update aioairzone to v0.0.5 - Add token refresh and relogin support. - Improve fetching installation devices. Signed-off-by: Álvaro Fernández Rojas * airzone_cloud: add to strict typing Signed-off-by: Álvaro Fernández Rojas * Update aioairzone to v0.0.7 Signed-off-by: Álvaro Fernández Rojas * trigger CI * airzone_cloud: remove unneeded api_get_user call Signed-off-by: Álvaro Fernández Rojas * Add Airzone brand Signed-off-by: Álvaro Fernández Rojas * Update aioairzone to v0.1.1 Signed-off-by: Álvaro Fernández Rojas * airzone_cloud: use unique_id instead of entry_id Signed-off-by: Álvaro Fernández Rojas * airzone_cloud: remove special handling of TooManyRequests Signed-off-by: Álvaro Fernández Rojas * airzone_cloud: bump coordinator timeout to 30s Signed-off-by: Álvaro Fernández Rojas * airzone_cloud: make AirzoneEntity an ABC Signed-off-by: Álvaro Fernández Rojas * airzone_cloud: fix strings typo Signed-off-by: Álvaro Fernández Rojas * tests: airzone_cloud: simplify webserver mock Signed-off-by: Álvaro Fernández Rojas * Update aioairzone-cloud to v0.1.2 Signed-off-by: Álvaro Fernández Rojas --------- Signed-off-by: Álvaro Fernández Rojas --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/brands/airzone.json | 5 + .../components/airzone_cloud/__init__.py | 48 +++++ .../components/airzone_cloud/config_flow.py | 116 ++++++++++++ .../components/airzone_cloud/const.py | 8 + .../components/airzone_cloud/coordinator.py | 43 +++++ .../components/airzone_cloud/entity.py | 54 ++++++ .../components/airzone_cloud/manifest.json | 10 + .../components/airzone_cloud/sensor.py | 97 ++++++++++ .../components/airzone_cloud/strings.json | 19 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 17 +- mypy.ini | 10 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/airzone_cloud/__init__.py | 1 + .../airzone_cloud/test_config_flow.py | 143 ++++++++++++++ .../airzone_cloud/test_coordinator.py | 70 +++++++ tests/components/airzone_cloud/test_init.py | 43 +++++ tests/components/airzone_cloud/test_sensor.py | 26 +++ tests/components/airzone_cloud/util.py | 175 ++++++++++++++++++ 22 files changed, 892 insertions(+), 3 deletions(-) create mode 100644 homeassistant/brands/airzone.json create mode 100644 homeassistant/components/airzone_cloud/__init__.py create mode 100644 homeassistant/components/airzone_cloud/config_flow.py create mode 100644 homeassistant/components/airzone_cloud/const.py create mode 100644 homeassistant/components/airzone_cloud/coordinator.py create mode 100644 homeassistant/components/airzone_cloud/entity.py create mode 100644 homeassistant/components/airzone_cloud/manifest.json create mode 100644 homeassistant/components/airzone_cloud/sensor.py create mode 100644 homeassistant/components/airzone_cloud/strings.json create mode 100644 tests/components/airzone_cloud/__init__.py create mode 100644 tests/components/airzone_cloud/test_config_flow.py create mode 100644 tests/components/airzone_cloud/test_coordinator.py create mode 100644 tests/components/airzone_cloud/test_init.py create mode 100644 tests/components/airzone_cloud/test_sensor.py create mode 100644 tests/components/airzone_cloud/util.py diff --git a/.strict-typing b/.strict-typing index fc40996a37bf..9ed4bf534970 100644 --- a/.strict-typing +++ b/.strict-typing @@ -49,6 +49,7 @@ homeassistant.components.air_quality.* homeassistant.components.airly.* homeassistant.components.airvisual.* homeassistant.components.airzone.* +homeassistant.components.airzone_cloud.* homeassistant.components.aladdin_connect.* homeassistant.components.alarm_control_panel.* homeassistant.components.alert.* diff --git a/CODEOWNERS b/CODEOWNERS index b3ae7e942150..8a4e095e14b1 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -59,6 +59,8 @@ build.json @home-assistant/supervisor /tests/components/airvisual_pro/ @bachya /homeassistant/components/airzone/ @Noltari /tests/components/airzone/ @Noltari +/homeassistant/components/airzone_cloud/ @Noltari +/tests/components/airzone_cloud/ @Noltari /homeassistant/components/aladdin_connect/ @mkmer /tests/components/aladdin_connect/ @mkmer /homeassistant/components/alarm_control_panel/ @home-assistant/core diff --git a/homeassistant/brands/airzone.json b/homeassistant/brands/airzone.json new file mode 100644 index 000000000000..b41d1cb2e1cd --- /dev/null +++ b/homeassistant/brands/airzone.json @@ -0,0 +1,5 @@ +{ + "domain": "airzone", + "name": "Airzone", + "integrations": ["airzone", "airzone_cloud"] +} diff --git a/homeassistant/components/airzone_cloud/__init__.py b/homeassistant/components/airzone_cloud/__init__.py new file mode 100644 index 000000000000..cdc0f30a5337 --- /dev/null +++ b/homeassistant/components/airzone_cloud/__init__.py @@ -0,0 +1,48 @@ +"""The Airzone Cloud integration.""" +from __future__ import annotations + +from aioairzone_cloud.cloudapi import AirzoneCloudApi +from aioairzone_cloud.common import ConnectionOptions + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client + +from .const import DOMAIN +from .coordinator import AirzoneUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Airzone Cloud from a config entry.""" + options = ConnectionOptions( + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + ) + + airzone = AirzoneCloudApi(aiohttp_client.async_get_clientsession(hass), options) + await airzone.login() + inst_list = await airzone.list_installations() + for inst in inst_list: + if inst.get_id() == entry.data[CONF_ID]: + airzone.select_installation(inst) + await airzone.update_installation(inst) + + coordinator = AirzoneUpdateCoordinator(hass, airzone) + 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/airzone_cloud/config_flow.py b/homeassistant/components/airzone_cloud/config_flow.py new file mode 100644 index 000000000000..32274d4e8efc --- /dev/null +++ b/homeassistant/components/airzone_cloud/config_flow.py @@ -0,0 +1,116 @@ +"""Config flow for Airzone Cloud.""" +from __future__ import annotations + +from typing import Any + +from aioairzone_cloud.cloudapi import AirzoneCloudApi +from aioairzone_cloud.common import ConnectionOptions +from aioairzone_cloud.const import AZD_ID, AZD_NAME, AZD_WEBSERVERS +from aioairzone_cloud.exceptions import AirzoneCloudError, LoginError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) + +from .const import DOMAIN + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle config flow for an Airzone Cloud device.""" + + airzone: AirzoneCloudApi + + async def async_step_inst_pick( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the installation selection.""" + errors = {} + options: dict[str, str] = {} + + inst_desc = None + inst_id = None + if user_input is not None: + inst_id = user_input[CONF_ID] + + try: + inst_list = await self.airzone.list_installations() + except AirzoneCloudError: + errors["base"] = "cannot_connect" + else: + for inst in inst_list: + _data = inst.data() + _id = _data[AZD_ID] + options[_id] = f"{_data[AZD_NAME]} {_data[AZD_WEBSERVERS][0]} ({_id})" + if _id is not None and _id == inst_id: + inst_desc = options[_id] + + if user_input is not None and inst_desc is not None: + await self.async_set_unique_id(inst_id) + self._abort_if_unique_id_configured() + + user_input[CONF_USERNAME] = self.airzone.options.username + user_input[CONF_PASSWORD] = self.airzone.options.password + + return self.async_create_entry(title=inst_desc, data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_ID): SelectSelector( + SelectSelectorConfig( + options=[ + SelectOptionDict(value=k, label=v) + for k, v in options.items() + ], + mode=SelectSelectorMode.DROPDOWN, + ) + ), + } + ), + errors=errors, + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors = {} + + if user_input is not None: + if CONF_ID in user_input: + return await self.async_step_inst_pick(user_input) + + self.airzone = AirzoneCloudApi( + aiohttp_client.async_get_clientsession(self.hass), + ConnectionOptions( + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + ), + ) + + try: + await self.airzone.login() + except (AirzoneCloudError, LoginError): + errors["base"] = "cannot_connect" + else: + return await self.async_step_inst_pick() + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/airzone_cloud/const.py b/homeassistant/components/airzone_cloud/const.py new file mode 100644 index 000000000000..625d897188de --- /dev/null +++ b/homeassistant/components/airzone_cloud/const.py @@ -0,0 +1,8 @@ +"""Constants for the Airzone Cloud integration.""" + +from typing import Final + +DOMAIN: Final[str] = "airzone_cloud" +MANUFACTURER: Final[str] = "Airzone" + +AIOAIRZONE_CLOUD_TIMEOUT_SEC: Final[int] = 30 diff --git a/homeassistant/components/airzone_cloud/coordinator.py b/homeassistant/components/airzone_cloud/coordinator.py new file mode 100644 index 000000000000..edd993550920 --- /dev/null +++ b/homeassistant/components/airzone_cloud/coordinator.py @@ -0,0 +1,43 @@ +"""The Airzone Cloud integration coordinator.""" +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import Any + +from aioairzone_cloud.cloudapi import AirzoneCloudApi +from aioairzone_cloud.exceptions import AirzoneCloudError +import async_timeout + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import AIOAIRZONE_CLOUD_TIMEOUT_SEC, DOMAIN + +SCAN_INTERVAL = timedelta(seconds=60) + +_LOGGER = logging.getLogger(__name__) + + +class AirzoneUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Class to manage fetching data from the Airzone Cloud device.""" + + def __init__(self, hass: HomeAssistant, airzone: AirzoneCloudApi) -> None: + """Initialize.""" + self.airzone = airzone + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + + async def _async_update_data(self) -> dict[str, Any]: + """Update data via library.""" + async with async_timeout.timeout(AIOAIRZONE_CLOUD_TIMEOUT_SEC): + try: + await self.airzone.update() + except AirzoneCloudError as error: + raise UpdateFailed(error) from error + return self.airzone.data() diff --git a/homeassistant/components/airzone_cloud/entity.py b/homeassistant/components/airzone_cloud/entity.py new file mode 100644 index 000000000000..bf02bf1ccf82 --- /dev/null +++ b/homeassistant/components/airzone_cloud/entity.py @@ -0,0 +1,54 @@ +"""Entity classes for the Airzone Cloud integration.""" +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any + +from aioairzone_cloud.const import AZD_NAME, AZD_SYSTEM_ID, AZD_ZONES + +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MANUFACTURER +from .coordinator import AirzoneUpdateCoordinator + + +class AirzoneEntity(CoordinatorEntity[AirzoneUpdateCoordinator], ABC): + """Define an Airzone Cloud entity.""" + + @abstractmethod + def get_airzone_value(self, key: str) -> Any: + """Return Airzone Cloud entity value by key.""" + + +class AirzoneZoneEntity(AirzoneEntity): + """Define an Airzone Cloud Zone entity.""" + + def __init__( + self, + coordinator: AirzoneUpdateCoordinator, + entry: ConfigEntry, + zone_id: str, + zone_data: dict[str, Any], + ) -> None: + """Initialize.""" + super().__init__(coordinator) + + self.system_id = zone_data[AZD_SYSTEM_ID] + self.zone_id = zone_id + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{entry.unique_id}_{zone_id}")}, + manufacturer=MANUFACTURER, + name=zone_data[AZD_NAME], + via_device=(DOMAIN, f"{entry.unique_id}_{self.system_id}"), + ) + + def get_airzone_value(self, key: str) -> Any: + """Return zone value by key.""" + value = None + if zone := self.coordinator.data[AZD_ZONES].get(self.zone_id): + if key in zone: + value = zone[key] + return value diff --git a/homeassistant/components/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json new file mode 100644 index 000000000000..cc0e05f59689 --- /dev/null +++ b/homeassistant/components/airzone_cloud/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "airzone_cloud", + "name": "Airzone Cloud", + "codeowners": ["@Noltari"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/airzone_cloud", + "iot_class": "cloud_polling", + "loggers": ["aioairzone_cloud"], + "requirements": ["aioairzone-cloud==0.1.2"] +} diff --git a/homeassistant/components/airzone_cloud/sensor.py b/homeassistant/components/airzone_cloud/sensor.py new file mode 100644 index 000000000000..9df7518fa624 --- /dev/null +++ b/homeassistant/components/airzone_cloud/sensor.py @@ -0,0 +1,97 @@ +"""Support for the Airzone Cloud sensors.""" +from __future__ import annotations + +from typing import Any, Final + +from aioairzone_cloud.const import AZD_HUMIDITY, AZD_NAME, AZD_TEMP, AZD_ZONES + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE, UnitOfTemperature +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import AirzoneUpdateCoordinator +from .entity import AirzoneEntity, AirzoneZoneEntity + +ZONE_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = ( + SensorEntityDescription( + device_class=SensorDeviceClass.TEMPERATURE, + key=AZD_TEMP, + name="Temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + device_class=SensorDeviceClass.HUMIDITY, + key=AZD_HUMIDITY, + name="Humidity", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Add Airzone Cloud sensors from a config_entry.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + + sensors = [] + for zone_id, zone_data in coordinator.data[AZD_ZONES].items(): + for description in ZONE_SENSOR_TYPES: + if description.key in zone_data: + sensors.append( + AirzoneZoneSensor( + coordinator, + description, + entry, + zone_id, + zone_data, + ) + ) + + async_add_entities(sensors) + + +class AirzoneSensor(AirzoneEntity, SensorEntity): + """Define an Airzone Cloud sensor.""" + + @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 sensor attributes.""" + self._attr_native_value = self.get_airzone_value(self.entity_description.key) + + +class AirzoneZoneSensor(AirzoneZoneEntity, AirzoneSensor): + """Define an Airzone Cloud Zone sensor.""" + + def __init__( + self, + coordinator: AirzoneUpdateCoordinator, + description: SensorEntityDescription, + entry: ConfigEntry, + zone_id: str, + zone_data: dict[str, Any], + ) -> None: + """Initialize.""" + super().__init__(coordinator, entry, zone_id, zone_data) + + self._attr_name = f"{zone_data[AZD_NAME]} {description.name}" + self._attr_unique_id = f"{entry.unique_id}_{zone_id}_{description.key}" + self.entity_description = description + + self._async_update_attrs() diff --git a/homeassistant/components/airzone_cloud/strings.json b/homeassistant/components/airzone_cloud/strings.json new file mode 100644 index 000000000000..12f155b44868 --- /dev/null +++ b/homeassistant/components/airzone_cloud/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "step": { + "user": { + "data": { + "id": "Installation", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 1b76aa2b56d4..399e54c3b557 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -32,6 +32,7 @@ FLOWS = { "airvisual", "airvisual_pro", "airzone", + "airzone_cloud", "aladdin_connect", "alarmdecoder", "amberelectric", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index c9cd79e60dc3..c9ce171e3d4b 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -137,9 +137,20 @@ }, "airzone": { "name": "Airzone", - "integration_type": "hub", - "config_flow": true, - "iot_class": "local_polling" + "integrations": { + "airzone": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling", + "name": "Airzone" + }, + "airzone_cloud": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling", + "name": "Airzone Cloud" + } + } }, "aladdin_connect": { "name": "Aladdin Connect", diff --git a/mypy.ini b/mypy.ini index fd5e87bd499e..b2c6b7a9c835 100644 --- a/mypy.ini +++ b/mypy.ini @@ -251,6 +251,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.airzone_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.aladdin_connect.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index ef7c225d8c41..cf5b9fd441f6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -115,6 +115,9 @@ aio_georss_gdacs==0.8 # homeassistant.components.airq aioairq==0.2.4 +# homeassistant.components.airzone_cloud +aioairzone-cloud==0.1.2 + # homeassistant.components.airzone aioairzone==0.5.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 689c731a057a..7a43f7cee3af 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -105,6 +105,9 @@ aio_georss_gdacs==0.8 # homeassistant.components.airq aioairq==0.2.4 +# homeassistant.components.airzone_cloud +aioairzone-cloud==0.1.2 + # homeassistant.components.airzone aioairzone==0.5.6 diff --git a/tests/components/airzone_cloud/__init__.py b/tests/components/airzone_cloud/__init__.py new file mode 100644 index 000000000000..a00eeb760ebf --- /dev/null +++ b/tests/components/airzone_cloud/__init__.py @@ -0,0 +1 @@ +"""Tests for the Airzone Cloud integration.""" diff --git a/tests/components/airzone_cloud/test_config_flow.py b/tests/components/airzone_cloud/test_config_flow.py new file mode 100644 index 000000000000..477511b5f200 --- /dev/null +++ b/tests/components/airzone_cloud/test_config_flow.py @@ -0,0 +1,143 @@ +"""Define tests for the Airzone Cloud config flow.""" + +from unittest.mock import patch + +from aioairzone_cloud.exceptions import AirzoneCloudError, LoginError + +from homeassistant import data_entry_flow +from homeassistant.components.airzone_cloud.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER, ConfigEntryState +from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from .util import ( + CONFIG, + GET_INSTALLATION_MOCK, + GET_INSTALLATIONS_MOCK, + GET_WEBSERVER_MOCK, + WS_ID, + mock_get_device_status, +) + + +async def test_form(hass: HomeAssistant) -> None: + """Test that the form is served with valid input.""" + + with patch( + "homeassistant.components.airzone_cloud.async_setup_entry", + return_value=True, + ) as mock_setup_entry, patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_device_status", + side_effect=mock_get_device_status, + ), patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_installation", + return_value=GET_INSTALLATION_MOCK, + ), patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_installations", + return_value=GET_INSTALLATIONS_MOCK, + ), patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_webserver", + return_value=GET_WEBSERVER_MOCK, + ), patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.login", + return_value=None, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: CONFIG[CONF_USERNAME], + CONF_PASSWORD: CONFIG[CONF_PASSWORD], + }, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ID: CONFIG[CONF_ID], + }, + ) + + await hass.async_block_till_done() + + conf_entries = hass.config_entries.async_entries(DOMAIN) + entry = conf_entries[0] + assert entry.state is ConfigEntryState.LOADED + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["title"] == f"House {WS_ID} ({CONFIG[CONF_ID]})" + assert result["data"][CONF_ID] == CONFIG[CONF_ID] + assert result["data"][CONF_USERNAME] == CONFIG[CONF_USERNAME] + assert result["data"][CONF_PASSWORD] == CONFIG[CONF_PASSWORD] + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_installations_list_error(hass: HomeAssistant) -> None: + """Test connection error.""" + + with patch( + "homeassistant.components.airzone_cloud.async_setup_entry", + return_value=True, + ), patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_device_status", + side_effect=mock_get_device_status, + ), patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_installations", + side_effect=AirzoneCloudError, + ), patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_webserver", + return_value=GET_WEBSERVER_MOCK, + ), patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.login", + return_value=None, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: CONFIG[CONF_USERNAME], + CONF_PASSWORD: CONFIG[CONF_PASSWORD], + }, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_login_error(hass: HomeAssistant) -> None: + """Test login error.""" + + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.login", + side_effect=LoginError, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_USERNAME: CONFIG[CONF_USERNAME], + CONF_PASSWORD: CONFIG[CONF_PASSWORD], + }, + ) + + assert result["errors"] == {"base": "cannot_connect"} diff --git a/tests/components/airzone_cloud/test_coordinator.py b/tests/components/airzone_cloud/test_coordinator.py new file mode 100644 index 000000000000..3bea38698810 --- /dev/null +++ b/tests/components/airzone_cloud/test_coordinator.py @@ -0,0 +1,70 @@ +"""Define tests for the Airzone Cloud coordinator.""" + +from unittest.mock import patch + +from aioairzone_cloud.exceptions import AirzoneCloudError + +from homeassistant.components.airzone_cloud.const import DOMAIN +from homeassistant.components.airzone_cloud.coordinator import SCAN_INTERVAL +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.util.dt import utcnow + +from .util import ( + CONFIG, + GET_INSTALLATION_MOCK, + GET_INSTALLATIONS_MOCK, + GET_WEBSERVER_MOCK, + mock_get_device_status, +) + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_coordinator_client_connector_error(hass: HomeAssistant) -> None: + """Test ClientConnectorError on coordinator update.""" + + config_entry = MockConfigEntry( + data=CONFIG, + domain=DOMAIN, + unique_id="airzone_cloud_unique_id", + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_device_status", + side_effect=mock_get_device_status, + ) as mock_device_status, patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_installation", + return_value=GET_INSTALLATION_MOCK, + ) as mock_installation, patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_installations", + return_value=GET_INSTALLATIONS_MOCK, + ) as mock_installations, patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_webserver", + return_value=GET_WEBSERVER_MOCK, + ) as mock_webserver, patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.login", + return_value=None, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + mock_device_status.assert_called() + mock_installation.assert_awaited_once() + mock_installations.assert_called_once() + mock_webserver.assert_called_once() + + mock_device_status.reset_mock() + mock_installation.reset_mock() + mock_installations.reset_mock() + mock_webserver.reset_mock() + + mock_device_status.side_effect = AirzoneCloudError + async_fire_time_changed(hass, utcnow() + SCAN_INTERVAL) + await hass.async_block_till_done() + + mock_device_status.assert_called() + + state = hass.states.get("sensor.salon_temperature") + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/airzone_cloud/test_init.py b/tests/components/airzone_cloud/test_init.py new file mode 100644 index 000000000000..3a6497fdebac --- /dev/null +++ b/tests/components/airzone_cloud/test_init.py @@ -0,0 +1,43 @@ +"""Define tests for the Airzone Cloud init.""" + +from unittest.mock import patch + +from homeassistant.components.airzone_cloud.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from .util import CONFIG + +from tests.common import MockConfigEntry + + +async def test_unload_entry(hass: HomeAssistant) -> None: + """Test unload.""" + + config_entry = MockConfigEntry( + data=CONFIG, + domain=DOMAIN, + unique_id="airzone_cloud_unique_id", + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.login", + return_value=None, + ), patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.list_installations", + return_value=[], + ), patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.update_installation", + return_value=None, + ), patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.update", + return_value=None, + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/airzone_cloud/test_sensor.py b/tests/components/airzone_cloud/test_sensor.py new file mode 100644 index 000000000000..f8cbfbf2fdee --- /dev/null +++ b/tests/components/airzone_cloud/test_sensor.py @@ -0,0 +1,26 @@ +"""The sensor tests for the Airzone Cloud platform.""" + +from homeassistant.core import HomeAssistant + +from .util import async_init_integration + + +async def test_airzone_create_sensors( + hass: HomeAssistant, entity_registry_enabled_by_default: None +) -> None: + """Test creation of sensors.""" + + await async_init_integration(hass) + + # Zones + state = hass.states.get("sensor.dormitorio_temperature") + assert state.state == "25.0" + + state = hass.states.get("sensor.dormitorio_humidity") + assert state.state == "24" + + state = hass.states.get("sensor.salon_temperature") + assert state.state == "20.0" + + state = hass.states.get("sensor.salon_humidity") + assert state.state == "30" diff --git a/tests/components/airzone_cloud/util.py b/tests/components/airzone_cloud/util.py new file mode 100644 index 000000000000..c88221fc5cf3 --- /dev/null +++ b/tests/components/airzone_cloud/util.py @@ -0,0 +1,175 @@ +"""Tests for the Airzone integration.""" + +from typing import Any +from unittest.mock import patch + +from aioairzone_cloud.const import ( + API_AZ_SYSTEM, + API_AZ_ZONE, + API_CELSIUS, + API_CONFIG, + API_CONNECTION_DATE, + API_DEVICE_ID, + API_DEVICES, + API_DISCONNECTION_DATE, + API_FAH, + API_GROUPS, + API_HUMIDITY, + API_INSTALLATION_ID, + API_INSTALLATIONS, + API_IS_CONNECTED, + API_LOCAL_TEMP, + API_META, + API_NAME, + API_STAT_AP_MAC, + API_STAT_CHANNEL, + API_STAT_QUALITY, + API_STAT_SSID, + API_STATUS, + API_SYSTEM_NUMBER, + API_TYPE, + API_WS_FW, + API_WS_ID, + API_WS_IDS, + API_WS_TYPE, + API_ZONE_NUMBER, +) +from aioairzone_cloud.device import Device + +from homeassistant.components.airzone_cloud import DOMAIN +from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +WS_ID = "11:22:33:44:55:66" + +CONFIG = { + CONF_ID: "inst1", + CONF_USERNAME: "user", + CONF_PASSWORD: "pass", +} + +GET_INSTALLATION_MOCK = { + API_GROUPS: [ + { + API_NAME: "Group", + API_DEVICES: [ + { + API_DEVICE_ID: "system1", + API_TYPE: API_AZ_SYSTEM, + API_META: { + API_SYSTEM_NUMBER: 1, + }, + API_WS_ID: WS_ID, + }, + { + API_DEVICE_ID: "zone1", + API_NAME: "Salon", + API_TYPE: API_AZ_ZONE, + API_META: { + API_SYSTEM_NUMBER: 1, + API_ZONE_NUMBER: 1, + }, + API_WS_ID: WS_ID, + }, + { + API_DEVICE_ID: "zone2", + API_NAME: "Dormitorio", + API_TYPE: API_AZ_ZONE, + API_META: { + API_SYSTEM_NUMBER: 1, + API_ZONE_NUMBER: 2, + }, + API_WS_ID: WS_ID, + }, + ], + }, + ], +} + +GET_INSTALLATIONS_MOCK = { + API_INSTALLATIONS: [ + { + API_INSTALLATION_ID: CONFIG[CONF_ID], + API_NAME: "House", + API_WS_IDS: [ + WS_ID, + ], + }, + ], +} + +GET_WEBSERVER_MOCK = { + API_WS_TYPE: "ws_az", + API_CONFIG: { + API_WS_FW: "3.44", + API_STAT_SSID: "Wifi", + API_STAT_CHANNEL: 36, + API_STAT_AP_MAC: "00:00:00:00:00:00", + }, + API_STATUS: { + API_IS_CONNECTED: True, + API_STAT_QUALITY: 4, + API_CONNECTION_DATE: "2023-05-07T12:55:51.000Z", + API_DISCONNECTION_DATE: "2023-01-01T22:26:55.376Z", + }, +} + + +def mock_get_device_status(device: Device) -> dict[str, Any]: + """Mock API device status.""" + + if device.get_id() == "system1": + return { + API_IS_CONNECTED: True, + } + if device.get_id() == "zone2": + return { + API_HUMIDITY: 24, + API_IS_CONNECTED: True, + API_LOCAL_TEMP: { + API_FAH: 77, + API_CELSIUS: 25, + }, + } + return { + API_HUMIDITY: 30, + API_IS_CONNECTED: True, + API_LOCAL_TEMP: { + API_FAH: 68, + API_CELSIUS: 20, + }, + } + + +async def async_init_integration( + hass: HomeAssistant, +) -> None: + """Set up the Airzone integration in Home Assistant.""" + + config_entry = MockConfigEntry( + data=CONFIG, + domain=DOMAIN, + unique_id="airzone_cloud_unique_id", + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_device_status", + side_effect=mock_get_device_status, + ), patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_installation", + return_value=GET_INSTALLATION_MOCK, + ), patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_installations", + return_value=GET_INSTALLATIONS_MOCK, + ), patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_webserver", + return_value=GET_WEBSERVER_MOCK, + ), patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.login", + return_value=None, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() From 160fce781d15b43d13853ffcc33dd4cf9b6d46a4 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 23 May 2023 19:31:03 +0200 Subject: [PATCH 476/822] Bump httpx to 0.24.1 (#93396) --- 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 a13a8f847900..b235a7010f12 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -27,7 +27,7 @@ hassil==1.0.6 home-assistant-bluetooth==1.10.0 home-assistant-frontend==20230503.3 home-assistant-intents==2023.4.26 -httpx==0.24.0 +httpx==0.24.1 ifaddr==0.2.0 janus==1.0.0 jinja2==3.1.2 diff --git a/pyproject.toml b/pyproject.toml index 1bda10f06777..4a1a529d59ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,7 @@ dependencies = [ "ciso8601==2.3.0", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all - "httpx==0.24.0", + "httpx==0.24.1", "home-assistant-bluetooth==1.10.0", "ifaddr==0.2.0", "jinja2==3.1.2", diff --git a/requirements.txt b/requirements.txt index ca8bd2631593..818eeec8515c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,7 +10,7 @@ awesomeversion==22.9.0 bcrypt==4.0.1 certifi>=2021.5.30 ciso8601==2.3.0 -httpx==0.24.0 +httpx==0.24.1 home-assistant-bluetooth==1.10.0 ifaddr==0.2.0 jinja2==3.1.2 From fa366e59e0317c0c99633ec4516b286234ad0851 Mon Sep 17 00:00:00 2001 From: andarotajo <55669170+andarotajo@users.noreply.github.com> Date: Tue, 23 May 2023 19:34:47 +0200 Subject: [PATCH 477/822] Improve dwd_weather_warnings code quality (#92738) Improve code quality by removing unnecessary data --- .../dwd_weather_warnings/__init__.py | 4 +- .../dwd_weather_warnings/config_flow.py | 43 +++++++------------ .../components/dwd_weather_warnings/sensor.py | 12 +++--- .../dwd_weather_warnings/test_config_flow.py | 29 +------------ 4 files changed, 25 insertions(+), 63 deletions(-) diff --git a/homeassistant/components/dwd_weather_warnings/__init__.py b/homeassistant/components/dwd_weather_warnings/__init__.py index 99c3a110caa8..55309110cafb 100644 --- a/homeassistant/components/dwd_weather_warnings/__init__.py +++ b/homeassistant/components/dwd_weather_warnings/__init__.py @@ -17,9 +17,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Initialize the API. api = await hass.async_add_executor_job(DwdWeatherWarningsAPI, region_identifier) - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = api - + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = api await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/dwd_weather_warnings/config_flow.py b/homeassistant/components/dwd_weather_warnings/config_flow.py index 653812632c0d..e806db7ec91e 100644 --- a/homeassistant/components/dwd_weather_warnings/config_flow.py +++ b/homeassistant/components/dwd_weather_warnings/config_flow.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any, Final +from typing import Any from dwdwfsapi import DwdWeatherWarningsAPI import voluptuous as vol @@ -12,19 +12,7 @@ from homeassistant.const import CONF_NAME from homeassistant.data_entry_flow import FlowResult import homeassistant.helpers.config_validation as cv -from .const import ( - CONF_REGION_IDENTIFIER, - CONF_REGION_NAME, - DEFAULT_NAME, - DOMAIN, - LOGGER, -) - -CONFIG_SCHEMA: Final = vol.Schema( - { - vol.Required(CONF_REGION_IDENTIFIER): cv.string, - } -) +from .const import CONF_REGION_IDENTIFIER, CONF_REGION_NAME, DOMAIN, LOGGER class DwdWeatherWarningsConfigFlow(ConfigFlow, domain=DOMAIN): @@ -52,13 +40,16 @@ class DwdWeatherWarningsConfigFlow(ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(region_identifier) self._abort_if_unique_id_configured() - # Set the name for this config entry. - name = f"{DEFAULT_NAME} {region_identifier}" - - return self.async_create_entry(title=name, data=user_input) + return self.async_create_entry(title=region_identifier, data=user_input) return self.async_show_form( - step_id="user", errors=errors, data_schema=CONFIG_SCHEMA + step_id="user", + errors=errors, + data_schema=vol.Schema( + { + vol.Required(CONF_REGION_IDENTIFIER): cv.string, + } + ), ) async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: @@ -67,12 +58,12 @@ class DwdWeatherWarningsConfigFlow(ConfigFlow, domain=DOMAIN): "Starting import of sensor from configuration.yaml - %s", import_config ) - # Adjust data to new format. - region_identifier = import_config.pop(CONF_REGION_NAME) - import_config[CONF_REGION_IDENTIFIER] = region_identifier + # Extract the necessary data for the setup. + region_identifier = import_config[CONF_REGION_NAME] + name = import_config.get(CONF_NAME, region_identifier) # Set the unique ID for this imported entry. - await self.async_set_unique_id(import_config[CONF_REGION_IDENTIFIER]) + await self.async_set_unique_id(region_identifier) self._abort_if_unique_id_configured() # Validate region identifier using the API @@ -81,8 +72,6 @@ class DwdWeatherWarningsConfigFlow(ConfigFlow, domain=DOMAIN): ): return self.async_abort(reason="invalid_identifier") - name = import_config.get( - CONF_NAME, f"{DEFAULT_NAME} {import_config[CONF_REGION_IDENTIFIER]}" + return self.async_create_entry( + title=name, data={CONF_REGION_IDENTIFIER: region_identifier} ) - - return self.async_create_entry(title=name, data=import_config) diff --git a/homeassistant/components/dwd_weather_warnings/sensor.py b/homeassistant/components/dwd_weather_warnings/sensor.py index de96b3e9e086..26aa0ef1ba07 100644 --- a/homeassistant/components/dwd_weather_warnings/sensor.py +++ b/homeassistant/components/dwd_weather_warnings/sensor.py @@ -47,6 +47,7 @@ from .const import ( ATTR_WARNING_COUNT, CONF_REGION_NAME, CURRENT_WARNING_SENSOR, + DEFAULT_NAME, DEFAULT_SCAN_INTERVAL, DOMAIN, LOGGER, @@ -112,7 +113,7 @@ async def async_setup_entry( async_add_entities( [ - DwdWeatherWarningsSensor(api, entry.title, entry.unique_id, description) + DwdWeatherWarningsSensor(api, entry, description) for description in SENSOR_TYPES ], True, @@ -127,15 +128,14 @@ class DwdWeatherWarningsSensor(SensorEntity): def __init__( self, api, - name, - unique_id, + entry: ConfigEntry, description: SensorEntityDescription, ) -> None: """Initialize a DWD-Weather-Warnings sensor.""" - self._api = api self.entity_description = description - self._attr_name = f"{name} {description.name}" - self._attr_unique_id = f"{unique_id}-{description.key}" + self._attr_name = f"{DEFAULT_NAME} {entry.title} {description.name}" + self._attr_unique_id = f"{entry.unique_id}-{description.key}" + self._api = api @property def native_value(self): diff --git a/tests/components/dwd_weather_warnings/test_config_flow.py b/tests/components/dwd_weather_warnings/test_config_flow.py index 9c730eacffdf..819d98e25ef4 100644 --- a/tests/components/dwd_weather_warnings/test_config_flow.py +++ b/tests/components/dwd_weather_warnings/test_config_flow.py @@ -64,7 +64,7 @@ async def test_create_entry(hass: HomeAssistant) -> None: # Test for successfully created entry. await hass.async_block_till_done() assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "DWD Weather Warnings 807111000" + assert result["title"] == "807111000" assert result["data"] == { CONF_REGION_IDENTIFIER: "807111000", } @@ -102,9 +102,7 @@ async def test_import_flow_full_data(hass: HomeAssistant) -> None: assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == "Unit Test" assert result["data"] == { - CONF_NAME: "Unit Test", CONF_REGION_IDENTIFIER: "807111000", - CONF_MONITORED_CONDITIONS: [CURRENT_WARNING_SENSOR, ADVANCE_WARNING_SENSOR], } @@ -123,30 +121,7 @@ async def test_import_flow_no_name(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "DWD Weather Warnings 807111000" - assert result["data"] == { - CONF_REGION_IDENTIFIER: "807111000", - CONF_MONITORED_CONDITIONS: [CURRENT_WARNING_SENSOR, ADVANCE_WARNING_SENSOR], - } - - -async def test_import_flow_only_required(hass: HomeAssistant) -> None: - """Test a successful import of a YAML configuration with only required properties.""" - data = DEMO_YAML_CONFIGURATION.copy() - data.pop(CONF_NAME) - data.pop(CONF_MONITORED_CONDITIONS) - - with patch( - "homeassistant.components.dwd_weather_warnings.config_flow.DwdWeatherWarningsAPI", - return_value=True, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=data - ) - - await hass.async_block_till_done() - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "DWD Weather Warnings 807111000" + assert result["title"] == "807111000" assert result["data"] == { CONF_REGION_IDENTIFIER: "807111000", } From 46103f56c4d1c601b30832c18ffb84f62d56bda9 Mon Sep 17 00:00:00 2001 From: Patrick Li Date: Wed, 24 May 2023 06:11:25 +1200 Subject: [PATCH 478/822] Fix trusted_users group schema (#92847) * Fix trusted_users group schema * Add tests for CONFIG_SCHEMA --- .../auth/providers/trusted_networks.py | 2 +- tests/auth/providers/test_trusted_networks.py | 26 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/homeassistant/auth/providers/trusted_networks.py b/homeassistant/auth/providers/trusted_networks.py index a6c4c19b02fc..04db5fc287b7 100644 --- a/homeassistant/auth/providers/trusted_networks.py +++ b/homeassistant/auth/providers/trusted_networks.py @@ -46,7 +46,7 @@ CONFIG_SCHEMA = AUTH_PROVIDER_SCHEMA.extend( [ vol.Or( cv.uuid4_hex, - vol.Schema({vol.Required(CONF_GROUP): cv.uuid4_hex}), + vol.Schema({vol.Required(CONF_GROUP): str}), ) ], ) diff --git a/tests/auth/providers/test_trusted_networks.py b/tests/auth/providers/test_trusted_networks.py index 53d1f0a9fa76..a098eea28e07 100644 --- a/tests/auth/providers/test_trusted_networks.py +++ b/tests/auth/providers/test_trusted_networks.py @@ -116,6 +116,32 @@ def manager_bypass_login(hass, store, provider_bypass_login): ) +async def test_config_schema(): + """Test CONFIG_SCHEMA.""" + # Valid configuration + tn_auth.CONFIG_SCHEMA( + { + "type": "trusted_networks", + "trusted_networks": ["192.168.0.1"], + "trusted_users": { + "192.168.0.1": [ + "a1ab982744b64757bf80515589258924", + {"group": "system-group"}, + ] + }, + } + ) + # Wrong user id format + with pytest.raises(vol.Invalid): + tn_auth.CONFIG_SCHEMA( + { + "type": "trusted_networks", + "trusted_networks": ["192.168.0.1"], + "trusted_users": {"192.168.0.1": ["abcde"]}, + } + ) + + async def test_trusted_networks_credentials(manager, provider) -> None: """Test trusted_networks credentials related functions.""" owner = await manager.async_create_user("test-owner") From 56d2048ffa3a58973d2e6c09a962c1060faf908a Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Tue, 23 May 2023 20:42:09 +0200 Subject: [PATCH 479/822] Bump Matter server library to 3.4.1 and address changes (#93411) * bump python matter server to 3.4.1 * address renamed attribute names in sdk 1.1 * ignore AllClustersAppServerExample * clusters.ColorControl.Bitmaps.ColorCapabilities * address discovery schemas * fix all fixtures due to attribute rename * bump python matter server to 3.4.1 * address renamed attribute names in sdk 1.1 * ignore AllClustersAppServerExample * clusters.ColorControl.Bitmaps.ColorCapabilities * address discovery schemas * fix all fixtures due to attribute rename * lint * update requirements_all --- .../components/matter/binary_sensor.py | 2 +- homeassistant/components/matter/light.py | 39 +++++++++---------- homeassistant/components/matter/lock.py | 2 +- homeassistant/components/matter/manifest.json | 2 +- homeassistant/components/matter/switch.py | 7 +++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../nodes/color-temperature-light.json | 4 +- .../matter/fixtures/nodes/contact-sensor.json | 4 +- .../fixtures/nodes/device_diagnostics.json | 4 +- .../matter/fixtures/nodes/dimmable-light.json | 4 +- .../matter/fixtures/nodes/door-lock.json | 4 +- .../fixtures/nodes/extended-color-light.json | 4 +- .../matter/fixtures/nodes/flow-sensor.json | 4 +- .../fixtures/nodes/humidity-sensor.json | 4 +- .../matter/fixtures/nodes/light-sensor.json | 4 +- .../fixtures/nodes/occupancy-sensor.json | 4 +- .../fixtures/nodes/on-off-plugin-unit.json | 4 +- .../matter/fixtures/nodes/onoff-light.json | 4 +- .../fixtures/nodes/pressure-sensor.json | 4 +- .../fixtures/nodes/temperature-sensor.json | 4 +- .../fixtures/nodes/window-covering.json | 4 +- tests/components/matter/test_light.py | 4 +- 23 files changed, 61 insertions(+), 59 deletions(-) diff --git a/homeassistant/components/matter/binary_sensor.py b/homeassistant/components/matter/binary_sensor.py index a82614cbcc69..bd65b3a09257 100644 --- a/homeassistant/components/matter/binary_sensor.py +++ b/homeassistant/components/matter/binary_sensor.py @@ -104,7 +104,7 @@ DISCOVERY_SCHEMAS = [ device_class=BinarySensorDeviceClass.BATTERY, name="Battery Status", measurement_to_ha=lambda x: x - != clusters.PowerSource.Enums.BatChargeLevel.kOk, + != clusters.PowerSource.Enums.BatChargeLevelEnum.kOk, ), entity_class=MatterBinarySensor, required_attributes=(clusters.PowerSource.Attributes.BatChargeLevel,), diff --git a/homeassistant/components/matter/light.py b/homeassistant/components/matter/light.py index 10a52eb88055..ae2b7a68c3a7 100644 --- a/homeassistant/components/matter/light.py +++ b/homeassistant/components/matter/light.py @@ -1,7 +1,6 @@ """Matter light.""" from __future__ import annotations -from enum import IntFlag from typing import Any from chip.clusters import Objects as clusters @@ -112,7 +111,7 @@ class MatterLight(MatterEntity, LightEntity): await self.send_device_command( clusters.ColorControl.Commands.MoveToColorTemperature( - colorTemperature=color_temp, + colorTemperatureMireds=color_temp, # It's required in TLV. We don't implement transition time yet. transitionTime=0, ) @@ -307,13 +306,22 @@ class MatterLight(MatterEntity, LightEntity): assert capabilities is not None - if capabilities & ColorCapabilities.kHueSaturationSupported: + if ( + capabilities + & clusters.ColorControl.Bitmaps.ColorCapabilities.kHueSaturationSupported + ): supported_color_modes.add(ColorMode.HS) - if capabilities & ColorCapabilities.kXYAttributesSupported: + if ( + capabilities + & clusters.ColorControl.Bitmaps.ColorCapabilities.kXYAttributesSupported + ): supported_color_modes.add(ColorMode.XY) - if capabilities & ColorCapabilities.kColorTemperatureSupported: + if ( + capabilities + & clusters.ColorControl.Bitmaps.ColorCapabilities.kColorTemperatureSupported + ): supported_color_modes.add(ColorMode.COLOR_TEMP) self._attr_supported_color_modes = supported_color_modes @@ -344,18 +352,6 @@ class MatterLight(MatterEntity, LightEntity): self._attr_brightness = self._get_brightness() -# This enum should be removed once the ColorControlCapabilities enum is added to the CHIP (Matter) library -# clusters.ColorControl.Bitmap.ColorCapabilities -class ColorCapabilities(IntFlag): - """Color control capabilities bitmap.""" - - kHueSaturationSupported = 0x1 - kEnhancedHueSupported = 0x2 - kColorLoopSupported = 0x4 - kXYAttributesSupported = 0x8 - kColorTemperatureSupported = 0x10 - - # Discovery schema(s) to map Matter Attributes to HA entities DISCOVERY_SCHEMAS = [ MatterDiscoverySchema( @@ -372,10 +368,11 @@ DISCOVERY_SCHEMAS = [ clusters.ColorControl.Attributes.CurrentY, clusters.ColorControl.Attributes.ColorTemperatureMireds, ), - # restrict device type to prevent discovery by the wrong platform - not_device_type=( - device_types.OnOffPlugInUnit, - device_types.DoorLock, + device_type=( + device_types.ColorTemperatureLight, + device_types.DimmableLight, + device_types.ExtendedColorLight, + device_types.OnOffLight, ), ), ] diff --git a/homeassistant/components/matter/lock.py b/homeassistant/components/matter/lock.py index f90d8eb485d0..c529ee12c5f2 100644 --- a/homeassistant/components/matter/lock.py +++ b/homeassistant/components/matter/lock.py @@ -106,7 +106,7 @@ class MatterLock(MatterEntity, LockEntity): LOGGER.debug("Door state: %s for %s", door_state, self.entity_id) self._attr_is_jammed = ( - door_state is clusters.DoorLock.Enums.DlDoorState.kDoorJammed + door_state is clusters.DoorLock.Enums.DoorStateEnum.kDoorJammed ) diff --git a/homeassistant/components/matter/manifest.json b/homeassistant/components/matter/manifest.json index 190bf33dcf71..5af01f2eea53 100644 --- a/homeassistant/components/matter/manifest.json +++ b/homeassistant/components/matter/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["websocket_api"], "documentation": "https://www.home-assistant.io/integrations/matter", "iot_class": "local_push", - "requirements": ["python-matter-server==3.2.0"] + "requirements": ["python-matter-server==3.4.1"] } diff --git a/homeassistant/components/matter/switch.py b/homeassistant/components/matter/switch.py index 809d0ad73861..2eb3c22c1f7a 100644 --- a/homeassistant/components/matter/switch.py +++ b/homeassistant/components/matter/switch.py @@ -69,9 +69,14 @@ DISCOVERY_SCHEMAS = [ required_attributes=(clusters.OnOff.Attributes.OnOff,), # restrict device type to prevent discovery by the wrong platform not_device_type=( - device_types.OnOffLight, + device_types.ColorTemperatureLight, device_types.DimmableLight, + device_types.ExtendedColorLight, + device_types.OnOffLight, device_types.DoorLock, + device_types.ColorDimmerSwitch, + device_types.DimmerSwitch, + device_types.OnOffLightSwitch, ), ), ] diff --git a/requirements_all.txt b/requirements_all.txt index cf5b9fd441f6..e700233999ce 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2089,7 +2089,7 @@ python-kasa==0.5.1 # python-lirc==1.2.3 # homeassistant.components.matter -python-matter-server==3.2.0 +python-matter-server==3.4.1 # homeassistant.components.xiaomi_miio python-miio==0.5.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7a43f7cee3af..38f0ea091589 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1518,7 +1518,7 @@ python-juicenet==1.1.0 python-kasa==0.5.1 # homeassistant.components.matter -python-matter-server==3.2.0 +python-matter-server==3.4.1 # homeassistant.components.xiaomi_miio python-miio==0.5.12 diff --git a/tests/components/matter/fixtures/nodes/color-temperature-light.json b/tests/components/matter/fixtures/nodes/color-temperature-light.json index 2155f20fe3a3..f5a6d5fd1e9c 100644 --- a/tests/components/matter/fixtures/nodes/color-temperature-light.json +++ b/tests/components/matter/fixtures/nodes/color-temperature-light.json @@ -6,7 +6,7 @@ "attributes": { "0/29/0": [ { - "type": 22, + "deviceType": 22, "revision": 1 } ], @@ -202,7 +202,7 @@ ], "1/29/0": [ { - "type": 268, + "deviceType": 268, "revision": 1 } ], diff --git a/tests/components/matter/fixtures/nodes/contact-sensor.json b/tests/components/matter/fixtures/nodes/contact-sensor.json index fa90ecff1d51..3500c73f7909 100644 --- a/tests/components/matter/fixtures/nodes/contact-sensor.json +++ b/tests/components/matter/fixtures/nodes/contact-sensor.json @@ -6,7 +6,7 @@ "attributes": { "0/29/0": [ { - "type": 22, + "deviceType": 22, "revision": 1 } ], @@ -61,7 +61,7 @@ "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "type": 21, + "deviceType": 21, "revision": 1 } ], diff --git a/tests/components/matter/fixtures/nodes/device_diagnostics.json b/tests/components/matter/fixtures/nodes/device_diagnostics.json index 2950a61622ce..c0e1e8980283 100644 --- a/tests/components/matter/fixtures/nodes/device_diagnostics.json +++ b/tests/components/matter/fixtures/nodes/device_diagnostics.json @@ -12,7 +12,7 @@ "0/4/65531": [0, 65528, 65529, 65531, 65532, 65533], "0/29/0": [ { - "type": 22, + "deviceType": 22, "revision": 1 } ], @@ -414,7 +414,7 @@ ], "1/29/0": [ { - "type": 257, + "deviceType": 257, "revision": 1 } ], diff --git a/tests/components/matter/fixtures/nodes/dimmable-light.json b/tests/components/matter/fixtures/nodes/dimmable-light.json index f295e3bf1545..32dfd29e796a 100644 --- a/tests/components/matter/fixtures/nodes/dimmable-light.json +++ b/tests/components/matter/fixtures/nodes/dimmable-light.json @@ -12,7 +12,7 @@ "0/4/65531": [0, 65528, 65529, 65531, 65532, 65533], "0/29/0": [ { - "type": 22, + "deviceType": 22, "revision": 1 } ], @@ -354,7 +354,7 @@ ], "1/29/0": [ { - "type": 257, + "deviceType": 257, "revision": 1 } ], diff --git a/tests/components/matter/fixtures/nodes/door-lock.json b/tests/components/matter/fixtures/nodes/door-lock.json index f7a9749325fe..62162b3c2d7b 100644 --- a/tests/components/matter/fixtures/nodes/door-lock.json +++ b/tests/components/matter/fixtures/nodes/door-lock.json @@ -7,7 +7,7 @@ "attributes": { "0/29/0": [ { - "type": 22, + "deviceType": 22, "revision": 1 } ], @@ -443,7 +443,7 @@ ], "1/29/0": [ { - "type": 10, + "deviceType": 10, "revision": 1 } ], diff --git a/tests/components/matter/fixtures/nodes/extended-color-light.json b/tests/components/matter/fixtures/nodes/extended-color-light.json index ac2a840d0411..e8c4603ab9cc 100644 --- a/tests/components/matter/fixtures/nodes/extended-color-light.json +++ b/tests/components/matter/fixtures/nodes/extended-color-light.json @@ -6,7 +6,7 @@ "attributes": { "0/29/0": [ { - "type": 22, + "deviceType": 22, "revision": 1 } ], @@ -202,7 +202,7 @@ ], "1/29/0": [ { - "type": 269, + "deviceType": 269, "revision": 1 } ], diff --git a/tests/components/matter/fixtures/nodes/flow-sensor.json b/tests/components/matter/fixtures/nodes/flow-sensor.json index 3bbeb51151a8..4e9efad22680 100644 --- a/tests/components/matter/fixtures/nodes/flow-sensor.json +++ b/tests/components/matter/fixtures/nodes/flow-sensor.json @@ -6,7 +6,7 @@ "attributes": { "0/29/0": [ { - "type": 22, + "deviceType": 22, "revision": 1 } ], @@ -56,7 +56,7 @@ "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "type": 774, + "deviceType": 774, "revision": 1 } ], diff --git a/tests/components/matter/fixtures/nodes/humidity-sensor.json b/tests/components/matter/fixtures/nodes/humidity-sensor.json index 92153e615166..23dcf667c585 100644 --- a/tests/components/matter/fixtures/nodes/humidity-sensor.json +++ b/tests/components/matter/fixtures/nodes/humidity-sensor.json @@ -6,7 +6,7 @@ "attributes": { "0/29/0": [ { - "type": 22, + "deviceType": 22, "revision": 1 } ], @@ -56,7 +56,7 @@ "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "type": 775, + "deviceType": 775, "revision": 1 } ], diff --git a/tests/components/matter/fixtures/nodes/light-sensor.json b/tests/components/matter/fixtures/nodes/light-sensor.json index 5ea56033a786..6289cb77da56 100644 --- a/tests/components/matter/fixtures/nodes/light-sensor.json +++ b/tests/components/matter/fixtures/nodes/light-sensor.json @@ -6,7 +6,7 @@ "attributes": { "0/29/0": [ { - "type": 22, + "deviceType": 22, "revision": 1 } ], @@ -56,7 +56,7 @@ "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "type": 262, + "deviceType": 262, "revision": 1 } ], diff --git a/tests/components/matter/fixtures/nodes/occupancy-sensor.json b/tests/components/matter/fixtures/nodes/occupancy-sensor.json index a541fdf4e776..cac06cbae20d 100644 --- a/tests/components/matter/fixtures/nodes/occupancy-sensor.json +++ b/tests/components/matter/fixtures/nodes/occupancy-sensor.json @@ -6,7 +6,7 @@ "attributes": { "0/29/0": [ { - "type": 22, + "deviceType": 22, "revision": 1 } ], @@ -61,7 +61,7 @@ "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "type": 263, + "deviceType": 263, "revision": 1 } ], diff --git a/tests/components/matter/fixtures/nodes/on-off-plugin-unit.json b/tests/components/matter/fixtures/nodes/on-off-plugin-unit.json index f037ba80bc0f..376426057af9 100644 --- a/tests/components/matter/fixtures/nodes/on-off-plugin-unit.json +++ b/tests/components/matter/fixtures/nodes/on-off-plugin-unit.json @@ -6,7 +6,7 @@ "attributes": { "0/29/0": [ { - "type": 22, + "deviceType": 22, "revision": 1 } ], @@ -118,7 +118,7 @@ ], "1/29/0": [ { - "type": 266, + "deviceType": 266, "revision": 1 } ], diff --git a/tests/components/matter/fixtures/nodes/onoff-light.json b/tests/components/matter/fixtures/nodes/onoff-light.json index fa6ed7afeffb..3db9105562ae 100644 --- a/tests/components/matter/fixtures/nodes/onoff-light.json +++ b/tests/components/matter/fixtures/nodes/onoff-light.json @@ -12,7 +12,7 @@ "0/4/65531": [0, 65528, 65529, 65531, 65532, 65533], "0/29/0": [ { - "type": 22, + "deviceType": 22, "revision": 1 } ], @@ -354,7 +354,7 @@ ], "1/29/0": [ { - "type": 257, + "deviceType": 257, "revision": 1 } ], diff --git a/tests/components/matter/fixtures/nodes/pressure-sensor.json b/tests/components/matter/fixtures/nodes/pressure-sensor.json index 7be49b650b5e..60335aa602d8 100644 --- a/tests/components/matter/fixtures/nodes/pressure-sensor.json +++ b/tests/components/matter/fixtures/nodes/pressure-sensor.json @@ -6,7 +6,7 @@ "attributes": { "0/29/0": [ { - "type": 22, + "deviceType": 22, "revision": 1 } ], @@ -56,7 +56,7 @@ "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "type": 773, + "deviceType": 773, "revision": 1 } ], diff --git a/tests/components/matter/fixtures/nodes/temperature-sensor.json b/tests/components/matter/fixtures/nodes/temperature-sensor.json index dd23fbda2cc6..426b66536238 100644 --- a/tests/components/matter/fixtures/nodes/temperature-sensor.json +++ b/tests/components/matter/fixtures/nodes/temperature-sensor.json @@ -6,7 +6,7 @@ "attributes": { "0/29/0": [ { - "type": 22, + "deviceType": 22, "revision": 1 } ], @@ -61,7 +61,7 @@ "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "type": 770, + "deviceType": 770, "revision": 1 } ], diff --git a/tests/components/matter/fixtures/nodes/window-covering.json b/tests/components/matter/fixtures/nodes/window-covering.json index 5ab0d4972789..9214b9511be6 100644 --- a/tests/components/matter/fixtures/nodes/window-covering.json +++ b/tests/components/matter/fixtures/nodes/window-covering.json @@ -7,7 +7,7 @@ "attributes": { "0/29/0": [ { - "type": 22, + "deviceType": 22, "revision": 1 } ], @@ -281,7 +281,7 @@ "1/4/65531": [0, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "type": 514, + "deviceType": 514, "revision": 1 } ], diff --git a/tests/components/matter/test_light.py b/tests/components/matter/test_light.py index ef8541120086..78ffa477b33e 100644 --- a/tests/components/matter/test_light.py +++ b/tests/components/matter/test_light.py @@ -188,7 +188,7 @@ async def test_color_temperature_light( "turn_on", { "entity_id": entity_id, - "color_temp": 3000, + "color_temp": 300, }, blocking=True, ) @@ -200,7 +200,7 @@ async def test_color_temperature_light( node_id=light_node.node_id, endpoint_id=1, command=clusters.ColorControl.Commands.MoveToColorTemperature( - colorTemperature=3003, + colorTemperatureMireds=300, transitionTime=0, ), ), From f6e7b727b0e2e1b3d1051b681ff88d9195e899f7 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Wed, 24 May 2023 05:32:47 +1000 Subject: [PATCH 480/822] Add Fan and Dry HVAC modes to Advantage Air MyTemp preset (#93189) --- .../components/advantage_air/climate.py | 42 +++++++------------ 1 file changed, 15 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/advantage_air/climate.py b/homeassistant/components/advantage_air/climate.py index a13fa95f6ba7..6170bd165e9a 100644 --- a/homeassistant/components/advantage_air/climate.py +++ b/homeassistant/components/advantage_air/climate.py @@ -91,6 +91,16 @@ class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity): _attr_max_temp = 32 _attr_min_temp = 16 + _attr_hvac_modes = [ + HVACMode.OFF, + HVACMode.COOL, + HVACMode.HEAT, + HVACMode.FAN_ONLY, + HVACMode.DRY, + ] + + _attr_supported_features = ClimateEntityFeature.FAN_MODE + def __init__(self, instance: AdvantageAirData, ac_key: str) -> None: """Initialize an AdvantageAir AC unit.""" super().__init__(instance, ac_key) @@ -98,36 +108,14 @@ class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity): # Set supported features and HVAC modes based on current operating mode if self._ac.get(ADVANTAGE_AIR_MYAUTO_ENABLED): # MyAuto - self._attr_supported_features = ( - ClimateEntityFeature.FAN_MODE - | ClimateEntityFeature.TARGET_TEMPERATURE + self._attr_supported_features |= ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE ) - self._attr_hvac_modes = [ - HVACMode.OFF, - HVACMode.COOL, - HVACMode.HEAT, - HVACMode.FAN_ONLY, - HVACMode.DRY, - HVACMode.HEAT_COOL, - ] - elif self._ac.get(ADVANTAGE_AIR_MYTEMP_ENABLED): - # MyTemp - self._attr_supported_features = ClimateEntityFeature.FAN_MODE - self._attr_hvac_modes = [HVACMode.OFF, HVACMode.COOL, HVACMode.HEAT] - - else: + self._attr_hvac_modes += [HVACMode.HEAT_COOL] + elif not self._ac.get(ADVANTAGE_AIR_MYTEMP_ENABLED): # MyZone - self._attr_supported_features = ( - ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.TARGET_TEMPERATURE - ) - self._attr_hvac_modes = [ - HVACMode.OFF, - HVACMode.COOL, - HVACMode.HEAT, - HVACMode.FAN_ONLY, - HVACMode.DRY, - ] + self._attr_supported_features |= ClimateEntityFeature.TARGET_TEMPERATURE # Add "ezfan" mode if supported if self._ac.get(ADVANTAGE_AIR_AUTOFAN): From f09abb0f2e0da4fb5158608652a85cb1bb4c110c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 May 2023 14:47:31 -0500 Subject: [PATCH 481/822] Fix race in tracking pending writes in recorder (#93414) --- homeassistant/components/recorder/core.py | 34 +++++++++++++---------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 43915c0187b6..67d3bff3b2af 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -215,6 +215,7 @@ class Recorder(threading.Thread): self.schema_version = 0 self._commits_without_expire = 0 + self._event_session_has_pending_writes = False self.recorder_runs_manager = RecorderRunsManager() self.states_manager = StatesManager() @@ -322,7 +323,7 @@ class Recorder(threading.Thread): if ( self._event_listener and not self._database_lock_task - and self._event_session_has_pending_writes() + and self._event_session_has_pending_writes ): self.queue_task(COMMIT_TASK) @@ -688,6 +689,11 @@ class Recorder(threading.Thread): # anything goes wrong in the run loop self._shutdown() + def _add_to_session(self, session: Session, obj: object) -> None: + """Add an object to the session.""" + self._event_session_has_pending_writes = True + session.add(obj) + def _run(self) -> None: """Start processing events to save.""" self.thread_id = threading.get_ident() @@ -1016,11 +1022,11 @@ class Recorder(threading.Thread): else: event_types = EventTypes(event_type=event.event_type) event_type_manager.add_pending(event_types) - session.add(event_types) + self._add_to_session(session, event_types) dbevent.event_type_rel = event_types if not event.data: - session.add(dbevent) + self._add_to_session(session, dbevent) return event_data_manager = self.event_data_manager @@ -1042,10 +1048,10 @@ class Recorder(threading.Thread): # No matching attributes found, save them in the DB dbevent_data = EventData(shared_data=shared_data, hash=hash_) event_data_manager.add_pending(dbevent_data) - session.add(dbevent_data) + self._add_to_session(session, dbevent_data) dbevent.event_data_rel = dbevent_data - session.add(dbevent) + self._add_to_session(session, dbevent) def _process_state_changed_event_into_session(self, event: Event) -> None: """Process a state_changed event into the session.""" @@ -1090,7 +1096,7 @@ class Recorder(threading.Thread): else: states_meta = StatesMeta(entity_id=entity_id) states_meta_manager.add_pending(states_meta) - session.add(states_meta) + self._add_to_session(session, states_meta) dbstate.states_meta_rel = states_meta # Map the event data to the StateAttributes table @@ -1115,10 +1121,10 @@ class Recorder(threading.Thread): # No matching attributes found, save them in the DB dbstate_attributes = StateAttributes(shared_attrs=shared_attrs, hash=hash_) state_attributes_manager.add_pending(dbstate_attributes) - session.add(dbstate_attributes) + self._add_to_session(session, dbstate_attributes) dbstate.state_attributes = dbstate_attributes - session.add(dbstate) + self._add_to_session(session, dbstate) def _handle_database_error(self, err: Exception) -> bool: """Handle a database error that may result in moving away the corrupt db.""" @@ -1130,14 +1136,9 @@ class Recorder(threading.Thread): return True return False - def _event_session_has_pending_writes(self) -> bool: - """Return True if there are pending writes in the event session.""" - session = self.event_session - return bool(session and (session.new or session.dirty)) - def _commit_event_session_or_retry(self) -> None: """Commit the event session if there is work to do.""" - if not self._event_session_has_pending_writes(): + if not self._event_session_has_pending_writes: return tries = 1 while tries <= self.db_max_retries: @@ -1163,6 +1164,7 @@ class Recorder(threading.Thread): self._commits_without_expire += 1 session.commit() + self._event_session_has_pending_writes = False # We just committed the state attributes to the database # and we now know the attributes_ids. We can save # many selects for matching attributes by loading them @@ -1263,7 +1265,7 @@ class Recorder(threading.Thread): async def async_block_till_done(self) -> None: """Async version of block_till_done.""" - if self._queue.empty() and not self._event_session_has_pending_writes(): + if self._queue.empty() and not self._event_session_has_pending_writes: return event = asyncio.Event() self.queue_task(SynchronizeTask(event)) @@ -1417,6 +1419,8 @@ class Recorder(threading.Thread): if self.event_session is None: return if self.recorder_runs_manager.active: + # .end will add to the event session + self._event_session_has_pending_writes = True self.recorder_runs_manager.end(self.event_session) try: self._commit_event_session_or_retry() From d8493a41aa5daa5cfda1068c6efb0f7ff3bf8ad9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 24 May 2023 00:21:49 -0500 Subject: [PATCH 482/822] Add Yale Home virtual integration (#93364) * Add Yale Home virtual integration Yale split all their customers outside of North America off into a new brand recently https://www.yalehome.com/in/en/news/news/introducing-the-new-yale-home-app#:~:text=The%20Yale%20Home%20app,products%20and%20new%20products%20alike. * init * brands --- homeassistant/brands/yale.json | 2 +- homeassistant/components/yale_home/__init__.py | 1 + homeassistant/components/yale_home/manifest.json | 6 ++++++ homeassistant/generated/integrations.json | 6 ++++++ 4 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/yale_home/__init__.py create mode 100644 homeassistant/components/yale_home/manifest.json diff --git a/homeassistant/brands/yale.json b/homeassistant/brands/yale.json index 87c119fdd40f..53dc9b435697 100644 --- a/homeassistant/brands/yale.json +++ b/homeassistant/brands/yale.json @@ -1,5 +1,5 @@ { "domain": "yale", "name": "Yale", - "integrations": ["august", "yale_smart_alarm", "yalexs_ble"] + "integrations": ["august", "yale_smart_alarm", "yalexs_ble", "yale_home"] } diff --git a/homeassistant/components/yale_home/__init__.py b/homeassistant/components/yale_home/__init__.py new file mode 100644 index 000000000000..8313f40e785a --- /dev/null +++ b/homeassistant/components/yale_home/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Yale Home.""" diff --git a/homeassistant/components/yale_home/manifest.json b/homeassistant/components/yale_home/manifest.json new file mode 100644 index 000000000000..0e45b0da7d03 --- /dev/null +++ b/homeassistant/components/yale_home/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "yale_home", + "name": "Yale Home", + "integration_type": "virtual", + "supported_by": "august" +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index c9ce171e3d4b..c97e229f5a34 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6388,6 +6388,12 @@ "config_flow": true, "iot_class": "local_push", "name": "Yale Access Bluetooth" + }, + "yale_home": { + "integration_type": "virtual", + "config_flow": false, + "supported_by": "august", + "name": "Yale Home" } } }, From a43dcaf81226b6eca4d9f874fbfc7808511d257b Mon Sep 17 00:00:00 2001 From: Tom Date: Wed, 24 May 2023 07:56:17 +0200 Subject: [PATCH 483/822] Plugwise add value_fn for select (#93375) * Plugwise prepare value_fn for select * Plugwise prepare value_fn for select * Try proposed options * Fix initial defs --------- Co-authored-by: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> --- homeassistant/components/plugwise/select.py | 27 +++++++++------------ 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/plugwise/select.py b/homeassistant/components/plugwise/select.py index c45d47004b8f..b78fd689cb93 100644 --- a/homeassistant/components/plugwise/select.py +++ b/homeassistant/components/plugwise/select.py @@ -5,7 +5,7 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any -from plugwise import Smile +from plugwise import DeviceData, Smile from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry @@ -23,8 +23,8 @@ class PlugwiseSelectDescriptionMixin: """Mixin values for Plugwise Select entities.""" command: Callable[[Smile, str, str], Awaitable[Any]] - current_option_key: str - options_key: str + value_fn: Callable[[DeviceData], str] + options_fn: Callable[[DeviceData], list[str]] @dataclass @@ -40,8 +40,8 @@ SELECT_TYPES = ( translation_key="select_schedule", icon="mdi:calendar-clock", command=lambda api, loc, opt: api.set_schedule_state(loc, opt, STATE_ON), - current_option_key="selected_schedule", - options_key="available_schedules", + value_fn=lambda data: data["selected_schedule"], + options_fn=lambda data: data.get("available_schedules"), ), PlugwiseSelectEntityDescription( key="select_regulation_mode", @@ -49,8 +49,8 @@ SELECT_TYPES = ( icon="mdi:hvac", entity_category=EntityCategory.CONFIG, command=lambda api, loc, opt: api.set_regulation_mode(opt), - current_option_key="regulation_mode", - options_key="regulation_modes", + value_fn=lambda data: data["regulation_mode"], + options_fn=lambda data: data.get("regulation_modes"), ), PlugwiseSelectEntityDescription( key="select_dhw_mode", @@ -58,8 +58,8 @@ SELECT_TYPES = ( icon="mdi:shower", entity_category=EntityCategory.CONFIG, command=lambda api, loc, opt: api.set_dhw_mode(opt), - current_option_key="dhw_mode", - options_key="dhw_modes", + value_fn=lambda data: data["dhw_mode"], + options_fn=lambda data: data.get("dhw_modes"), ), ) @@ -77,10 +77,7 @@ async def async_setup_entry( entities: list[PlugwiseSelectEntity] = [] for device_id, device in coordinator.data.devices.items(): for description in SELECT_TYPES: - if ( - description.options_key in device - and len(device[description.options_key]) > 1 - ): + if (options := description.options_fn(device)) and len(options) > 1: entities.append( PlugwiseSelectEntity(coordinator, device_id, description) ) @@ -107,12 +104,12 @@ class PlugwiseSelectEntity(PlugwiseEntity, SelectEntity): @property def current_option(self) -> str: """Return the selected entity option to represent the entity state.""" - return self.device[self.entity_description.current_option_key] + return self.entity_description.value_fn(self.device) @property def options(self) -> list[str]: """Return the selectable entity options.""" - return self.device[self.entity_description.options_key] + return self.entity_description.options_fn(self.device) async def async_select_option(self, option: str) -> None: """Change to the selected entity option.""" From 869f970e59153c45d1d57f81a1cbdbc74834e47d Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Wed, 24 May 2023 08:16:09 +0200 Subject: [PATCH 484/822] Fix Snapcast connection issues (#93010) * Add (dis)connect and update listeners, terminate connection and reconnect. Set availability * Pass entry_id to constructor --- homeassistant/components/snapcast/__init__.py | 8 +- .../components/snapcast/media_player.py | 43 ++++-- homeassistant/components/snapcast/server.py | 140 +++++++++++++++++- 3 files changed, 173 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/snapcast/__init__.py b/homeassistant/components/snapcast/__init__.py index 309669a84968..d8ff55cc1753 100644 --- a/homeassistant/components/snapcast/__init__.py +++ b/homeassistant/components/snapcast/__init__.py @@ -27,7 +27,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: f"Could not connect to Snapcast server at {host}:{port}" ) from ex - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = HomeAssistantSnapcast(server) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = HomeAssistantSnapcast( + hass, server, f"{host}:{port}", entry.entry_id + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -37,5 +39,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) + snapcast_data = hass.data[DOMAIN].pop(entry.entry_id) + # disconnect from server + await snapcast_data.disconnect() return unload_ok diff --git a/homeassistant/components/snapcast/media_player.py b/homeassistant/components/snapcast/media_player.py index bb54bfabf9f0..624bf7463ba0 100644 --- a/homeassistant/components/snapcast/media_player.py +++ b/homeassistant/components/snapcast/media_player.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from snapcast.control.server import CONTROL_PORT +from snapcast.control.server import CONTROL_PORT, Snapserver import voluptuous as vol from homeassistant.components.media_player import ( @@ -34,7 +34,6 @@ from .const import ( SERVICE_SNAPSHOT, SERVICE_UNJOIN, ) -from .server import HomeAssistantSnapcast _LOGGER = logging.getLogger(__name__) @@ -72,7 +71,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the snapcast config entry.""" - snapcast_data: HomeAssistantSnapcast = hass.data[DOMAIN][config_entry.entry_id] + snapcast_server: Snapserver = hass.data[DOMAIN][config_entry.entry_id].server register_services() @@ -80,14 +79,18 @@ async def async_setup_entry( port = config_entry.data[CONF_PORT] hpid = f"{host}:{port}" - snapcast_data.groups = [ - SnapcastGroupDevice(group, hpid) for group in snapcast_data.server.groups + groups: list[MediaPlayerEntity] = [ + SnapcastGroupDevice(group, hpid, config_entry.entry_id) + for group in snapcast_server.groups ] - snapcast_data.clients = [ + clients: list[MediaPlayerEntity] = [ SnapcastClientDevice(client, hpid, config_entry.entry_id) - for client in snapcast_data.server.clients + for client in snapcast_server.clients ] - async_add_entities(snapcast_data.clients + snapcast_data.groups) + async_add_entities(clients + groups) + hass.data[DOMAIN][ + config_entry.entry_id + ].hass_async_add_entities = async_add_entities async def async_setup_platform( @@ -147,18 +150,27 @@ class SnapcastGroupDevice(MediaPlayerEntity): | MediaPlayerEntityFeature.SELECT_SOURCE ) - def __init__(self, group, uid_part): + def __init__(self, group, uid_part, entry_id): """Initialize the Snapcast group device.""" + self._attr_available = True self._group = group + self._entry_id = entry_id self._uid = f"{GROUP_PREFIX}{uid_part}_{self._group.identifier}" async def async_added_to_hass(self) -> None: """Subscribe to group events.""" self._group.set_callback(self.schedule_update_ha_state) + self.hass.data[DOMAIN][self._entry_id].groups.append(self) async def async_will_remove_from_hass(self) -> None: """Disconnect group object when removed.""" self._group.set_callback(None) + self.hass.data[DOMAIN][self._entry_id].groups.remove(self) + + def set_availability(self, available: bool) -> None: + """Set availability of group.""" + self._attr_available = available + self.schedule_update_ha_state() @property def state(self) -> MediaPlayerState | None: @@ -172,6 +184,11 @@ class SnapcastGroupDevice(MediaPlayerEntity): """Return the ID of snapcast group.""" return self._uid + @property + def identifier(self): + """Return the snapcast identifier.""" + return self._group.identifier + @property def name(self): """Return the name of the device.""" @@ -236,6 +253,7 @@ class SnapcastClientDevice(MediaPlayerEntity): def __init__(self, client, uid_part, entry_id): """Initialize the Snapcast client device.""" + self._attr_available = True self._client = client self._uid = f"{CLIENT_PREFIX}{uid_part}_{self._client.identifier}" self._entry_id = entry_id @@ -243,10 +261,17 @@ class SnapcastClientDevice(MediaPlayerEntity): async def async_added_to_hass(self) -> None: """Subscribe to client events.""" self._client.set_callback(self.schedule_update_ha_state) + self.hass.data[DOMAIN][self._entry_id].clients.append(self) async def async_will_remove_from_hass(self) -> None: """Disconnect client object when removed.""" self._client.set_callback(None) + self.hass.data[DOMAIN][self._entry_id].clients.remove(self) + + def set_availability(self, available: bool) -> None: + """Set availability of group.""" + self._attr_available = available + self.schedule_update_ha_state() @property def unique_id(self): diff --git a/homeassistant/components/snapcast/server.py b/homeassistant/components/snapcast/server.py index 507ad6393a2e..6a787dd5e883 100644 --- a/homeassistant/components/snapcast/server.py +++ b/homeassistant/components/snapcast/server.py @@ -1,15 +1,141 @@ """Snapcast Integration.""" -from dataclasses import dataclass, field +from __future__ import annotations -from snapcast.control import Snapserver +import logging + +import snapcast.control +from snapcast.control.client import Snapclient from homeassistant.components.media_player import MediaPlayerEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .media_player import SnapcastClientDevice, SnapcastGroupDevice + +_LOGGER = logging.getLogger(__name__) -@dataclass class HomeAssistantSnapcast: - """Snapcast data stored in the Home Assistant data object.""" + """Snapcast server and data stored in the Home Assistant data object.""" - server: Snapserver - clients: list[MediaPlayerEntity] = field(default_factory=list) - groups: list[MediaPlayerEntity] = field(default_factory=list) + hass: HomeAssistant + + def __init__( + self, + hass: HomeAssistant, + server: snapcast.control.Snapserver, + hpid: str, + entry_id: str, + ) -> None: + """Initialize the HomeAssistantSnapcast object. + + Parameters + ---------- + hass: HomeAssistant + hass object + server : snapcast.control.Snapserver + Snapcast server + hpid : str + host and port + entry_id: str + ConfigEntry entry_id + + Returns + ------- + None + + """ + self.hass: HomeAssistant = hass + self.server: snapcast.control.Snapserver = server + self.hpid: str = hpid + self._entry_id = entry_id + self.clients: list[SnapcastClientDevice] = [] + self.groups: list[SnapcastGroupDevice] = [] + self.hass_async_add_entities: AddEntitiesCallback + # connect callbacks + self.server.set_on_update_callback(self.on_update) + self.server.set_on_connect_callback(self.on_connect) + self.server.set_on_disconnect_callback(self.on_disconnect) + self.server.set_new_client_callback(self.on_add_client) + + async def disconnect(self) -> None: + """Disconnect from server.""" + self.server.set_on_update_callback(None) + self.server.set_on_connect_callback(None) + self.server.set_on_disconnect_callback(None) + self.server.set_new_client_callback(None) + await self.server.stop() + + def on_update(self) -> None: + """Update all entities. + + Retrieve all groups/clients from server and add/update/delete entities. + """ + if not self.hass_async_add_entities: + return + new_groups: list[MediaPlayerEntity] = [] + groups: list[MediaPlayerEntity] = [] + hass_groups = {g.identifier: g for g in self.groups} + for group in self.server.groups: + if group.identifier in hass_groups: + groups.append(hass_groups[group.identifier]) + hass_groups[group.identifier].async_schedule_update_ha_state() + else: + new_groups.append(SnapcastGroupDevice(group, self.hpid, self._entry_id)) + new_clients: list[MediaPlayerEntity] = [] + clients: list[MediaPlayerEntity] = [] + hass_clients = {c.identifier: c for c in self.clients} + for client in self.server.clients: + if client.identifier in hass_clients: + clients.append(hass_clients[client.identifier]) + hass_clients[client.identifier].async_schedule_update_ha_state() + else: + new_clients.append( + SnapcastClientDevice(client, self.hpid, self._entry_id) + ) + del_entities: list[MediaPlayerEntity] = [ + x for x in self.groups if x not in groups + ] + del_entities.extend([x for x in self.clients if x not in clients]) + + _LOGGER.debug("New clients: %s", str(new_clients)) + _LOGGER.debug("New groups: %s", str(new_groups)) + _LOGGER.debug("Delete: %s", str(del_entities)) + + ent_reg = er.async_get(self.hass) + for entity in del_entities: + ent_reg.async_remove(entity.entity_id) + self.hass_async_add_entities(new_clients + new_groups) + + def on_connect(self) -> None: + """Activate all entities and update.""" + for client in self.clients: + client.set_availability(True) + for group in self.groups: + group.set_availability(True) + _LOGGER.info("Server connected: %s", self.hpid) + self.on_update() + + def on_disconnect(self, ex: Exception | None) -> None: + """Deactivate all entities.""" + for client in self.clients: + client.set_availability(False) + for group in self.groups: + group.set_availability(False) + _LOGGER.warning( + "Server disconnected: %s. Trying to reconnect. %s", self.hpid, str(ex or "") + ) + + def on_add_client(self, client: Snapclient) -> None: + """Add a Snapcast client. + + Parameters + ---------- + client : Snapclient + Snapcast client to be added to HA. + """ + if not self.hass_async_add_entities: + return + clients = [SnapcastClientDevice(client, self.hpid, self._entry_id)] + self.hass_async_add_entities(clients) From a29adc0a6a3376132822d3b1bfc955afb61db3c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Wed, 24 May 2023 08:21:24 +0200 Subject: [PATCH 485/822] Airzone select improvements (#92894) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * airzone: select: remove unneed .keys() Fixes late comment when select platform was introduced. Signed-off-by: Álvaro Fernández Rojas * airzone: select: make options translatable This was a late comment when the select platform was introduced. Signed-off-by: Álvaro Fernández Rojas * airzone: select: change options to lists (from enums) Signed-off-by: Álvaro Fernández Rojas * tests: airzone: fix python 3.10 tests Signed-off-by: Álvaro Fernández Rojas * airzone: select: add manual dict values This was requested in order to keep control of the translation strings. Signed-off-by: Álvaro Fernández Rojas * Revert "airzone: select: add manual dict values" This reverts commit b818a2674d6bdec7792fbb76e00b3a3a261d663b. * Revert "tests: airzone: fix python 3.10 tests" This reverts commit 93f8bd1430adece0a1296b4c44df8162437f82af. * Revert "airzone: select: change options to lists (from enums)" This reverts commit e503a1dd3abab2139220f44996afb6991aaa1ee9. * airzone: select: options: copy dict to list Signed-off-by: Álvaro Fernández Rojas * airzone: select: use degree symbol Signed-off-by: Álvaro Fernández Rojas --------- Signed-off-by: Álvaro Fernández Rojas --- homeassistant/components/airzone/select.py | 24 +++++++------ homeassistant/components/airzone/strings.json | 20 +++++++++++ tests/components/airzone/test_select.py | 36 +++++++++---------- 3 files changed, 51 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/airzone/select.py b/homeassistant/components/airzone/select.py index 023015121d72..1a0d577bb35f 100644 --- a/homeassistant/components/airzone/select.py +++ b/homeassistant/components/airzone/select.py @@ -1,7 +1,7 @@ """Support for the Airzone sensors.""" from __future__ import annotations -from dataclasses import dataclass, replace +from dataclasses import dataclass from typing import Any, Final from aioairzone.common import GrilleAngle, SleepTimeout @@ -41,14 +41,14 @@ class AirzoneSelectDescription(SelectEntityDescription, AirzoneSelectDescription GRILLE_ANGLE_DICT: Final[dict[str, int]] = { - "90º": GrilleAngle.DEG_90, - "50º": GrilleAngle.DEG_50, - "45º": GrilleAngle.DEG_45, - "40º": GrilleAngle.DEG_40, + "90deg": GrilleAngle.DEG_90, + "50deg": GrilleAngle.DEG_50, + "45deg": GrilleAngle.DEG_45, + "40deg": GrilleAngle.DEG_40, } SLEEP_DICT: Final[dict[str, int]] = { - "Off": SleepTimeout.SLEEP_OFF, + "off": SleepTimeout.SLEEP_OFF, "30m": SleepTimeout.SLEEP_30, "60m": SleepTimeout.SLEEP_60, "90m": SleepTimeout.SLEEP_90, @@ -61,21 +61,27 @@ ZONE_SELECT_TYPES: Final[tuple[AirzoneSelectDescription, ...]] = ( entity_category=EntityCategory.CONFIG, key=AZD_COLD_ANGLE, name="Cold Angle", + options=list(GRILLE_ANGLE_DICT), options_dict=GRILLE_ANGLE_DICT, + translation_key="grille_angles", ), AirzoneSelectDescription( api_param=API_HEAT_ANGLE, entity_category=EntityCategory.CONFIG, key=AZD_HEAT_ANGLE, name="Heat Angle", + options=list(GRILLE_ANGLE_DICT), options_dict=GRILLE_ANGLE_DICT, + translation_key="grille_angles", ), AirzoneSelectDescription( api_param=API_SLEEP, entity_category=EntityCategory.CONFIG, key=AZD_SLEEP, name="Sleep", + options=list(SLEEP_DICT), options_dict=SLEEP_DICT, + translation_key="sleep_times", ), ) @@ -91,14 +97,10 @@ async def async_setup_entry( for system_zone_id, zone_data in coordinator.data[AZD_ZONES].items(): for description in ZONE_SELECT_TYPES: if description.key in zone_data: - _desc = replace( - description, - options=list(description.options_dict.keys()), - ) entities.append( AirzoneZoneSelect( coordinator, - _desc, + description, entry, system_zone_id, zone_data, diff --git a/homeassistant/components/airzone/strings.json b/homeassistant/components/airzone/strings.json index 306e63da36c0..037ebe52d782 100644 --- a/homeassistant/components/airzone/strings.json +++ b/homeassistant/components/airzone/strings.json @@ -23,5 +23,25 @@ } } } + }, + "entity": { + "select": { + "grille_angles": { + "state": { + "90deg": "90°", + "50deg": "50°", + "45deg": "45°", + "40deg": "40°" + } + }, + "sleep_times": { + "state": { + "off": "[%key:common::state::off%]", + "30m": "30 minutes", + "60m": "60 minutes", + "90m": "90 minutes" + } + } + } } } diff --git a/tests/components/airzone/test_select.py b/tests/components/airzone/test_select.py index 545a45508de4..c7c320221236 100644 --- a/tests/components/airzone/test_select.py +++ b/tests/components/airzone/test_select.py @@ -25,49 +25,49 @@ async def test_airzone_create_selects(hass: HomeAssistant) -> None: await async_init_integration(hass) state = hass.states.get("select.despacho_cold_angle") - assert state.state == "90º" + assert state.state == "90deg" state = hass.states.get("select.despacho_heat_angle") - assert state.state == "90º" + assert state.state == "90deg" state = hass.states.get("select.despacho_sleep") - assert state.state == "Off" + assert state.state == "off" state = hass.states.get("select.dorm_1_cold_angle") - assert state.state == "90º" + assert state.state == "90deg" state = hass.states.get("select.dorm_1_heat_angle") - assert state.state == "90º" + assert state.state == "90deg" state = hass.states.get("select.dorm_1_sleep") - assert state.state == "Off" + assert state.state == "off" state = hass.states.get("select.dorm_2_cold_angle") - assert state.state == "90º" + assert state.state == "90deg" state = hass.states.get("select.dorm_2_heat_angle") - assert state.state == "90º" + assert state.state == "90deg" state = hass.states.get("select.dorm_2_sleep") - assert state.state == "Off" + assert state.state == "off" state = hass.states.get("select.dorm_ppal_cold_angle") - assert state.state == "45º" + assert state.state == "45deg" state = hass.states.get("select.dorm_ppal_heat_angle") - assert state.state == "50º" + assert state.state == "50deg" state = hass.states.get("select.dorm_ppal_sleep") assert state.state == "30m" state = hass.states.get("select.salon_cold_angle") - assert state.state == "90º" + assert state.state == "90deg" state = hass.states.get("select.salon_heat_angle") - assert state.state == "90º" + assert state.state == "90deg" state = hass.states.get("select.salon_sleep") - assert state.state == "Off" + assert state.state == "off" async def test_airzone_select_sleep(hass: HomeAssistant) -> None: @@ -140,13 +140,13 @@ async def test_airzone_select_grille_angle(hass: HomeAssistant) -> None: SERVICE_SELECT_OPTION, { ATTR_ENTITY_ID: "select.dorm_1_cold_angle", - ATTR_OPTION: "50º", + ATTR_OPTION: "50deg", }, blocking=True, ) state = hass.states.get("select.dorm_1_cold_angle") - assert state.state == "50º" + assert state.state == "50deg" # Heat Angle @@ -168,10 +168,10 @@ async def test_airzone_select_grille_angle(hass: HomeAssistant) -> None: SERVICE_SELECT_OPTION, { ATTR_ENTITY_ID: "select.dorm_1_heat_angle", - ATTR_OPTION: "45º", + ATTR_OPTION: "45deg", }, blocking=True, ) state = hass.states.get("select.dorm_1_heat_angle") - assert state.state == "45º" + assert state.state == "45deg" From 3c7e6bf3cc9ef7d837446be5d175c415b116381b Mon Sep 17 00:00:00 2001 From: Ludy Date: Wed, 24 May 2023 08:22:52 +0200 Subject: [PATCH 486/822] Add missing constants `trigger_type`and `condition_type` (#93415) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add missing `trigger_type`and ´condition_type` * auto generate `TURNED_ON` and `TURNED_OFF` --- .../binary_sensor/device_trigger.py | 59 +------------------ .../components/binary_sensor/strings.json | 4 ++ 2 files changed, 7 insertions(+), 56 deletions(-) diff --git a/homeassistant/components/binary_sensor/device_trigger.py b/homeassistant/components/binary_sensor/device_trigger.py index 969a52d15149..b2fd371a2604 100644 --- a/homeassistant/components/binary_sensor/device_trigger.py +++ b/homeassistant/components/binary_sensor/device_trigger.py @@ -70,62 +70,6 @@ CONF_OPENED = "opened" CONF_NOT_OPENED = "not_opened" -TURNED_ON = [ - CONF_BAT_LOW, - CONF_CO, - CONF_COLD, - CONF_CONNECTED, - CONF_GAS, - CONF_HOT, - CONF_LIGHT, - CONF_NOT_LOCKED, - CONF_MOIST, - CONF_MOTION, - CONF_MOVING, - CONF_OCCUPIED, - CONF_OPENED, - CONF_PLUGGED_IN, - CONF_POWERED, - CONF_PRESENT, - CONF_PROBLEM, - CONF_RUNNING, - CONF_SMOKE, - CONF_SOUND, - CONF_UNSAFE, - CONF_UPDATE, - CONF_VIBRATION, - CONF_TAMPERED, - CONF_TURNED_ON, -] - -TURNED_OFF = [ - CONF_NOT_BAT_LOW, - CONF_NOT_COLD, - CONF_NOT_CONNECTED, - CONF_NOT_HOT, - CONF_LOCKED, - CONF_NOT_MOIST, - CONF_NOT_MOVING, - CONF_NOT_OCCUPIED, - CONF_NOT_OPENED, - CONF_NOT_PLUGGED_IN, - CONF_NOT_POWERED, - CONF_NOT_PRESENT, - CONF_NOT_TAMPERED, - CONF_NOT_UNSAFE, - CONF_NO_CO, - CONF_NO_GAS, - CONF_NO_LIGHT, - CONF_NO_MOTION, - CONF_NO_PROBLEM, - CONF_NOT_RUNNING, - CONF_NO_SMOKE, - CONF_NO_SOUND, - CONF_NO_VIBRATION, - CONF_TURNED_OFF, -] - - ENTITY_TRIGGERS = { BinarySensorDeviceClass.BATTERY: [ {CONF_TYPE: CONF_BAT_LOW}, @@ -245,6 +189,9 @@ ENTITY_TRIGGERS = { ], } +TURNED_ON = [trigger[0][CONF_TYPE] for trigger in ENTITY_TRIGGERS.values()] +TURNED_OFF = [trigger[1][CONF_TYPE] for trigger in ENTITY_TRIGGERS.values()] + TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( { diff --git a/homeassistant/components/binary_sensor/strings.json b/homeassistant/components/binary_sensor/strings.json index f2bbc72e7a5f..d3045e6e8f7c 100644 --- a/homeassistant/components/binary_sensor/strings.json +++ b/homeassistant/components/binary_sensor/strings.json @@ -4,6 +4,8 @@ "condition_type": { "is_bat_low": "{entity_name} battery is low", "is_not_bat_low": "{entity_name} battery is normal", + "is_charging": "{entity_name} is charging", + "is_not_charging": "{entity_name} is not charging", "is_co": "{entity_name} is detecting carbon monoxide", "is_no_co": "{entity_name} is not detecting carbon monoxide", "is_cold": "{entity_name} is cold", @@ -56,6 +58,8 @@ "trigger_type": { "bat_low": "{entity_name} battery low", "not_bat_low": "{entity_name} battery normal", + "charging": "{entity_name} charging", + "not_charging": "{entity_name} not charging", "co": "{entity_name} started detecting carbon monoxide", "no_co": "{entity_name} stopped detecting carbon monoxide", "cold": "{entity_name} became cold", From 3bd6dea63b85fee47c38c1289fd25f6a36e9e46f Mon Sep 17 00:00:00 2001 From: Dara Adib Date: Wed, 24 May 2023 02:25:03 -0400 Subject: [PATCH 487/822] Add tests for Microsoft TTS (#92215) * Add tests for Microsoft TTS * Update requirements_test_all * Update tests/components/microsoft/__init__.py --------- Co-authored-by: Erik Montnemery --- requirements_test_all.txt | 3 + tests/components/microsoft/__init__.py | 1 + tests/components/microsoft/test_tts.py | 242 +++++++++++++++++++++++++ 3 files changed, 246 insertions(+) create mode 100644 tests/components/microsoft/__init__.py create mode 100644 tests/components/microsoft/test_tts.py diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 38f0ea091589..0595d6aa5887 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1159,6 +1159,9 @@ pycomfoconnect==0.5.1 # homeassistant.components.coolmaster pycoolmasternet-async==0.1.5 +# homeassistant.components.microsoft +pycsspeechtts==1.0.8 + # homeassistant.components.daikin pydaikin==2.9.0 diff --git a/tests/components/microsoft/__init__.py b/tests/components/microsoft/__init__.py new file mode 100644 index 000000000000..5c1ed8dad090 --- /dev/null +++ b/tests/components/microsoft/__init__.py @@ -0,0 +1 @@ +"""Tests for the Microsoft Text-to-Speech component.""" diff --git a/tests/components/microsoft/test_tts.py b/tests/components/microsoft/test_tts.py new file mode 100644 index 000000000000..d3f8e36e314d --- /dev/null +++ b/tests/components/microsoft/test_tts.py @@ -0,0 +1,242 @@ +"""Tests for Microsoft Text-to-Speech.""" +import os +import shutil +from unittest.mock import patch + +from pycsspeechtts import pycsspeechtts +import pytest +from requests import HTTPError + +from homeassistant.components import media_source, tts +from homeassistant.components.media_player import ( + ATTR_MEDIA_CONTENT_ID, + DOMAIN as DOMAIN_MP, + SERVICE_PLAY_MEDIA, +) +from homeassistant.components.microsoft.tts import SUPPORTED_LANGUAGES +from homeassistant.config import async_process_ha_core_config +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceNotFound +from homeassistant.setup import async_setup_component + +from tests.common import async_mock_service + + +async def get_media_source_url(hass: HomeAssistant, media_content_id): + """Get the media source url.""" + if media_source.DOMAIN not in hass.config.components: + assert await async_setup_component(hass, media_source.DOMAIN, {}) + + resolved = await media_source.async_resolve_media(hass, media_content_id, None) + return resolved.url + + +@pytest.fixture(autouse=True) +def cleanup_cache(hass: HomeAssistant): + """Clean up TTS cache.""" + yield + default_tts = hass.config.path(tts.DEFAULT_CACHE_DIR) + if os.path.isdir(default_tts): + shutil.rmtree(default_tts) + + +@pytest.fixture +async def calls(hass: HomeAssistant): + """Mock media player calls.""" + return async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) + + +@pytest.fixture(autouse=True) +async def setup_internal_url(hass: HomeAssistant): + """Set up internal url.""" + await async_process_ha_core_config( + hass, {"internal_url": "http://example.local:8123"} + ) + + +@pytest.fixture +def mock_tts(): + """Mock tts.""" + with patch( + "homeassistant.components.microsoft.tts.pycsspeechtts.TTSTranslator" + ) as mock_tts: + mock_tts.return_value.speak.return_value = b"" + yield mock_tts + + +async def test_service_say(hass: HomeAssistant, mock_tts, calls) -> None: + """Test service call say.""" + + await async_setup_component( + hass, tts.DOMAIN, {tts.DOMAIN: {"platform": "microsoft", "api_key": ""}} + ) + + await hass.services.async_call( + tts.DOMAIN, + "microsoft_say", + { + "entity_id": "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + }, + blocking=True, + ) + + assert len(calls) == 1 + url = await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert len(mock_tts.mock_calls) == 2 + assert url.endswith(".mp3") + + assert mock_tts.mock_calls[1][2] == { + "language": "en-us", + "gender": "Female", + "voiceType": "JennyNeural", + "output": "audio-24khz-96kbitrate-mono-mp3", + "rate": "0%", + "volume": "0%", + "pitch": "default", + "contour": "", + "text": "There is a person at the front door.", + } + + +async def test_service_say_en_gb_config(hass: HomeAssistant, mock_tts, calls) -> None: + """Test service call say with en-gb code in the config.""" + + await async_setup_component( + hass, + tts.DOMAIN, + { + tts.DOMAIN: { + "platform": "microsoft", + "api_key": "", + "language": "en-gb", + "type": "AbbiNeural", + } + }, + ) + + await hass.services.async_call( + tts.DOMAIN, + "microsoft_say", + { + "entity_id": "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + }, + blocking=True, + ) + + assert len(calls) == 1 + await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert len(mock_tts.mock_calls) == 2 + assert mock_tts.mock_calls[1][2] == { + "language": "en-gb", + "gender": "Female", + "voiceType": "AbbiNeural", + "output": "audio-24khz-96kbitrate-mono-mp3", + "rate": "0%", + "volume": "0%", + "pitch": "default", + "contour": "", + "text": "There is a person at the front door.", + } + + +async def test_service_say_en_gb_service(hass: HomeAssistant, mock_tts, calls) -> None: + """Test service call say with en-gb code in the service.""" + + await async_setup_component( + hass, + tts.DOMAIN, + {tts.DOMAIN: {"platform": "microsoft", "api_key": ""}}, + ) + + await hass.services.async_call( + tts.DOMAIN, + "microsoft_say", + { + "entity_id": "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_LANGUAGE: "en-gb", + tts.ATTR_OPTIONS: {"type": "AbbiNeural"}, + }, + blocking=True, + ) + + assert len(calls) == 1 + await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert len(mock_tts.mock_calls) == 2 + assert mock_tts.mock_calls[1][2] == { + "language": "en-gb", + "gender": "Female", + "voiceType": "AbbiNeural", + "output": "audio-24khz-96kbitrate-mono-mp3", + "rate": "0%", + "volume": "0%", + "pitch": "default", + "contour": "", + "text": "There is a person at the front door.", + } + + +def test_supported_languages() -> None: + """Test list of supported languages.""" + for lang in ["en-us", "en-gb"]: + assert lang in SUPPORTED_LANGUAGES + assert "en-US" not in SUPPORTED_LANGUAGES + for lang in [ + "en", + "en-uk", + "english", + "english (united states)", + "jennyneural", + "en-us-jennyneural", + ]: + assert lang not in {s.lower() for s in SUPPORTED_LANGUAGES} + assert len(SUPPORTED_LANGUAGES) > 70 + + +async def test_invalid_language(hass: HomeAssistant, mock_tts, calls) -> None: + """Test setup component with invalid language.""" + await async_setup_component( + hass, + tts.DOMAIN, + {tts.DOMAIN: {"platform": "microsoft", "api_key": "", "language": "en"}}, + ) + + with pytest.raises(ServiceNotFound): + await hass.services.async_call( + tts.DOMAIN, + "microsoft_say", + { + "entity_id": "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + }, + blocking=True, + ) + + assert len(calls) == 0 + assert len(mock_tts.mock_calls) == 0 + + +async def test_service_say_error(hass: HomeAssistant, mock_tts, calls) -> None: + """Test service call say with http error.""" + mock_tts.return_value.speak.side_effect = pycsspeechtts.requests.HTTPError + await async_setup_component( + hass, tts.DOMAIN, {tts.DOMAIN: {"platform": "microsoft", "api_key": ""}} + ) + + await hass.services.async_call( + tts.DOMAIN, + "microsoft_say", + { + "entity_id": "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + }, + blocking=True, + ) + + assert len(calls) == 1 + # Note: the integration currently catches HTTPException instead of HTTPError. + with pytest.raises(HTTPError): + await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert len(mock_tts.mock_calls) == 2 From 5bdc830664603a0a4bff9cd6566bcdad84f403f8 Mon Sep 17 00:00:00 2001 From: Chris Talkington Date: Wed, 24 May 2023 01:27:20 -0500 Subject: [PATCH 488/822] Bump rokuecp to 0.18.0 (#93424) --- homeassistant/components/roku/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/roku/fixtures/roku3-diagnostics-data.json | 1 + 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/roku/manifest.json b/homeassistant/components/roku/manifest.json index 944304aa6453..f9b81dc8ddd1 100644 --- a/homeassistant/components/roku/manifest.json +++ b/homeassistant/components/roku/manifest.json @@ -11,7 +11,7 @@ "iot_class": "local_polling", "loggers": ["rokuecp"], "quality_scale": "silver", - "requirements": ["rokuecp==0.17.1"], + "requirements": ["rokuecp==0.18.0"], "ssdp": [ { "st": "roku:ecp", diff --git a/requirements_all.txt b/requirements_all.txt index e700233999ce..30a568e849eb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2275,7 +2275,7 @@ rjpl==0.3.6 rocketchat-API==0.6.1 # homeassistant.components.roku -rokuecp==0.17.1 +rokuecp==0.18.0 # homeassistant.components.roomba roombapy==1.6.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0595d6aa5887..ca779485273a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1647,7 +1647,7 @@ rflink==0.0.65 ring_doorbell==0.7.2 # homeassistant.components.roku -rokuecp==0.17.1 +rokuecp==0.18.0 # homeassistant.components.roomba roombapy==1.6.8 diff --git a/tests/components/roku/fixtures/roku3-diagnostics-data.json b/tests/components/roku/fixtures/roku3-diagnostics-data.json index 70ef552dfa71..a3084b010c90 100644 --- a/tests/components/roku/fixtures/roku3-diagnostics-data.json +++ b/tests/components/roku/fixtures/roku3-diagnostics-data.json @@ -73,6 +73,7 @@ "supports_airplay": false, "supports_find_remote": false, "supports_private_listening": false, + "supports_wake_on_wlan": false, "version": "7.5.0", "wifi_mac": "b0:a7:37:96:4d:fb" }, From 67515f8ac3e4989ebb3e7dc3ff00159b236eaaba Mon Sep 17 00:00:00 2001 From: uvjustin <46082645+uvjustin@users.noreply.github.com> Date: Wed, 24 May 2023 14:28:04 +0800 Subject: [PATCH 489/822] Bump ha-av to v10.1.0 (#93420) --- homeassistant/components/generic/manifest.json | 2 +- homeassistant/components/stream/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/generic/manifest.json b/homeassistant/components/generic/manifest.json index 693959561d2f..fc06155121b0 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.0.0", "pillow==9.5.0"] + "requirements": ["ha-av==10.1.0", "pillow==9.5.0"] } diff --git a/homeassistant/components/stream/manifest.json b/homeassistant/components/stream/manifest.json index ef7a96e526ba..8b8e9b8a427f 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.6.7", "ha-av==10.0.0", "numpy==1.23.2"] + "requirements": ["PyTurboJPEG==1.6.7", "ha-av==10.1.0", "numpy==1.23.2"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b235a7010f12..45ca278e0527 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -21,7 +21,7 @@ ciso8601==2.3.0 cryptography==40.0.2 dbus-fast==1.86.0 fnv-hash-fast==0.3.1 -ha-av==10.0.0 +ha-av==10.1.0 hass-nabucasa==0.67.1 hassil==1.0.6 home-assistant-bluetooth==1.10.0 diff --git a/requirements_all.txt b/requirements_all.txt index 30a568e849eb..d83535b3e263 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -869,7 +869,7 @@ h2==4.1.0 # homeassistant.components.generic # homeassistant.components.stream -ha-av==10.0.0 +ha-av==10.1.0 # homeassistant.components.ffmpeg ha-ffmpeg==3.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ca779485273a..6cc23b26f9fa 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -673,7 +673,7 @@ h2==4.1.0 # homeassistant.components.generic # homeassistant.components.stream -ha-av==10.0.0 +ha-av==10.1.0 # homeassistant.components.ffmpeg ha-ffmpeg==3.1.0 From bb39b99a3f3d6964f1320934eb080388a7ebf79c Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 24 May 2023 18:37:42 +1200 Subject: [PATCH 490/822] ESPHome fix changing light color_mode (#93434) --- homeassistant/components/esphome/light.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/esphome/light.py b/homeassistant/components/esphome/light.py index 880d94a5f551..5e16f2476bb2 100644 --- a/homeassistant/components/esphome/light.py +++ b/homeassistant/components/esphome/light.py @@ -122,7 +122,7 @@ def _filter_color_modes( Excluding all values that don't have the requested features. """ - return [mode for mode in supported if mode & features] + return [mode for mode in supported if (mode & features) == features] class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): From abf08f66a4c7e67955213c228542884951d45a11 Mon Sep 17 00:00:00 2001 From: jjlawren Date: Wed, 24 May 2023 01:38:16 -0500 Subject: [PATCH 491/822] Bump `sonos-websocket` to 0.1.2 (#93429) Bump sonos-websocket to 0.1.2 --- homeassistant/components/sonos/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index 087c636f1ed8..fce34bde80a6 100644 --- a/homeassistant/components/sonos/manifest.json +++ b/homeassistant/components/sonos/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/sonos", "iot_class": "local_push", "loggers": ["soco"], - "requirements": ["soco==0.29.1", "sonos-websocket==0.1.1"], + "requirements": ["soco==0.29.1", "sonos-websocket==0.1.2"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:ZonePlayer:1" diff --git a/requirements_all.txt b/requirements_all.txt index d83535b3e263..80d0e22ca063 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2399,7 +2399,7 @@ solax==0.3.0 somfy-mylink-synergy==1.0.6 # homeassistant.components.sonos -sonos-websocket==0.1.1 +sonos-websocket==0.1.2 # homeassistant.components.marytts speak2mary==1.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6cc23b26f9fa..7118f312496a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1738,7 +1738,7 @@ solax==0.3.0 somfy-mylink-synergy==1.0.6 # homeassistant.components.sonos -sonos-websocket==0.1.1 +sonos-websocket==0.1.2 # homeassistant.components.marytts speak2mary==1.4.0 From 62ccecb690784b43a9048790c55f43dab4dcb4af Mon Sep 17 00:00:00 2001 From: ollo69 <60491700+ollo69@users.noreply.github.com> Date: Wed, 24 May 2023 09:10:51 +0200 Subject: [PATCH 492/822] Change utility_meter log level for invalid old state (#93026) --- homeassistant/components/utility_meter/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index 050e801f0566..7ad5afaa503d 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -411,7 +411,7 @@ class UtilityMeterSensor(RestoreSensor): if (old_state_val := self._validate_state(old_state)) is not None: return new_state_val - old_state_val - _LOGGER.warning( + _LOGGER.debug( "%s received an invalid state change coming from %s (%s > %s)", self.name, self._sensor_source_id, From 9363fa65618a7697a226fac7960c2388dc64afed Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Wed, 24 May 2023 09:16:41 +0200 Subject: [PATCH 493/822] Update quality scale of devolo Home Control (#92646) Update quality scale --- homeassistant/components/devolo_home_control/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/devolo_home_control/manifest.json b/homeassistant/components/devolo_home_control/manifest.json index 60f99daffdc5..71ca03f9638b 100644 --- a/homeassistant/components/devolo_home_control/manifest.json +++ b/homeassistant/components/devolo_home_control/manifest.json @@ -8,7 +8,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["devolo_home_control_api"], - "quality_scale": "silver", + "quality_scale": "gold", "requirements": ["devolo-home-control-api==0.18.2"], "zeroconf": ["_dvl-deviceapi._tcp.local."] } From 326d80f4f7bd9b04a820604a27ec8f42a2df246e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 24 May 2023 09:18:20 +0200 Subject: [PATCH 494/822] Move Twitch constants to separate file (#92605) * Move Twitch constants to separate file * Move Twitch constants to separate file * Move Twitch constants to separate file * Update branch --- homeassistant/components/twitch/const.py | 10 ++++++ homeassistant/components/twitch/sensor.py | 38 ++++++++++------------- 2 files changed, 26 insertions(+), 22 deletions(-) create mode 100644 homeassistant/components/twitch/const.py diff --git a/homeassistant/components/twitch/const.py b/homeassistant/components/twitch/const.py new file mode 100644 index 000000000000..6626889a8097 --- /dev/null +++ b/homeassistant/components/twitch/const.py @@ -0,0 +1,10 @@ +"""Const for Twitch.""" +import logging + +from twitchAPI.twitch import AuthScope + +LOGGER = logging.getLogger(__package__) + +CONF_CHANNELS = "channels" + +OAUTH_SCOPES = [AuthScope.USER_READ_SUBSCRIPTIONS] diff --git a/homeassistant/components/twitch/sensor.py b/homeassistant/components/twitch/sensor.py index fcc8225b73c9..3211ca1952bc 100644 --- a/homeassistant/components/twitch/sensor.py +++ b/homeassistant/components/twitch/sensor.py @@ -1,11 +1,8 @@ """Support for the Twitch stream status.""" from __future__ import annotations -import logging - from twitchAPI.helper import first from twitchAPI.twitch import ( - AuthScope, AuthType, InvalidTokenException, MissingScopeException, @@ -24,7 +21,17 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -_LOGGER = logging.getLogger(__name__) +from .const import CONF_CHANNELS, LOGGER, OAUTH_SCOPES + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_CLIENT_ID): cv.string, + vol.Required(CONF_CLIENT_SECRET): cv.string, + vol.Required(CONF_CHANNELS): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_TOKEN): cv.string, + } +) + ATTR_GAME = "game" ATTR_TITLE = "title" @@ -36,24 +43,11 @@ ATTR_FOLLOW_SINCE = "following_since" ATTR_FOLLOWING = "followers" ATTR_VIEWS = "views" -CONF_CHANNELS = "channels" - ICON = "mdi:twitch" STATE_OFFLINE = "offline" STATE_STREAMING = "streaming" -OAUTH_SCOPES = [AuthScope.USER_READ_SUBSCRIPTIONS] - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_CLIENT_ID): cv.string, - vol.Required(CONF_CLIENT_SECRET): cv.string, - vol.Required(CONF_CHANNELS): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_TOKEN): cv.string, - } -) - async def async_setup_platform( hass: HomeAssistant, @@ -75,7 +69,7 @@ async def async_setup_platform( ) client.auto_refresh_auth = False except TwitchAuthorizationException: - _LOGGER.error("Invalid client ID or client secret") + LOGGER.error("Invalid client ID or client secret") return if oauth_token: @@ -84,10 +78,10 @@ async def async_setup_platform( token=oauth_token, scope=OAUTH_SCOPES, validate=True ) except MissingScopeException: - _LOGGER.error("OAuth token is missing required scope") + LOGGER.error("OAuth token is missing required scope") return except InvalidTokenException: - _LOGGER.error("OAuth token is invalid") + LOGGER.error("OAuth token is invalid") return twitch_users: list[TwitchUser] = [] @@ -151,9 +145,9 @@ class TwitchSensor(SensorEntity): self._attr_extra_state_attributes[ATTR_SUBSCRIPTION] = True self._attr_extra_state_attributes[ATTR_SUBSCRIPTION_GIFTED] = sub.is_gift except TwitchResourceNotFound: - _LOGGER.debug("User is not subscribed") + LOGGER.debug("User is not subscribed") except TwitchAPIException as exc: - _LOGGER.error("Error response on check_user_subscription: %s", exc) + LOGGER.error("Error response on check_user_subscription: %s", exc) follows = ( await self._client.get_users_follows( From dc14b832494bbb2bd24bf310cd8c8348a57575c2 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 24 May 2023 09:38:29 +0200 Subject: [PATCH 495/822] Add ability to unload demo integration (#92515) * Add unload to demo integration * Adjust config tests to use kitchen_sink * Use async_unload_platforms --- homeassistant/components/demo/__init__.py | 8 ++++++++ tests/components/config/test_config_entries.py | 16 ++++++++++++---- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/demo/__init__.py b/homeassistant/components/demo/__init__.py index 85f7b89483f3..b941d31898cf 100644 --- a/homeassistant/components/demo/__init__.py +++ b/homeassistant/components/demo/__init__.py @@ -188,6 +188,14 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b return True +async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Unload a config entry.""" + await hass.config_entries.async_unload_platforms( + config_entry, COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM + ) + return True + + async def finish_setup(hass: HomeAssistant, config: ConfigType) -> None: """Finish set up once demo platforms are set up.""" switches: list[str] | None = None diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index d54abe772769..bbc6ce210cb1 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -216,7 +216,9 @@ async def test_get_entries(hass: HomeAssistant, client, clear_handlers) -> None: async def test_remove_entry(hass: HomeAssistant, client) -> None: """Test removing an entry via the API.""" - entry = MockConfigEntry(domain="demo", state=core_ce.ConfigEntryState.LOADED) + entry = MockConfigEntry( + domain="kitchen_sink", state=core_ce.ConfigEntryState.LOADED + ) entry.add_to_hass(hass) resp = await client.delete(f"/api/config/config_entries/entry/{entry.entry_id}") assert resp.status == HTTPStatus.OK @@ -227,7 +229,9 @@ async def test_remove_entry(hass: HomeAssistant, client) -> None: async def test_reload_entry(hass: HomeAssistant, client) -> None: """Test reloading an entry via the API.""" - entry = MockConfigEntry(domain="demo", state=core_ce.ConfigEntryState.LOADED) + entry = MockConfigEntry( + domain="kitchen_sink", state=core_ce.ConfigEntryState.LOADED + ) entry.add_to_hass(hass) resp = await client.post( f"/api/config/config_entries/entry/{entry.entry_id}/reload" @@ -1021,7 +1025,9 @@ async def test_update_prefrences( assert await async_setup_component(hass, "config", {}) ws_client = await hass_ws_client(hass) - entry = MockConfigEntry(domain="demo", state=core_ce.ConfigEntryState.LOADED) + entry = MockConfigEntry( + domain="kitchen_sink", state=core_ce.ConfigEntryState.LOADED + ) entry.add_to_hass(hass) assert entry.pref_disable_new_entities is False @@ -1118,7 +1124,9 @@ async def test_disable_entry( assert await async_setup_component(hass, "config", {}) ws_client = await hass_ws_client(hass) - entry = MockConfigEntry(domain="demo", state=core_ce.ConfigEntryState.LOADED) + entry = MockConfigEntry( + domain="kitchen_sink", state=core_ce.ConfigEntryState.LOADED + ) entry.add_to_hass(hass) assert entry.disabled_by is None From 7c72b97d792fd6b44c420ccbed3c0150584de69a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 24 May 2023 09:39:37 +0200 Subject: [PATCH 496/822] Use SnapshotAssertion in Renault tests (part 2) (#92395) --- .../renault/snapshots/test_binary_sensor.ambr | 1162 ++++++++ .../renault/snapshots/test_button.ambr | 538 ++++ .../snapshots/test_device_tracker.ambr | 268 ++ .../renault/snapshots/test_select.ambr | 298 ++ .../renault/snapshots/test_sensor.ambr | 2399 +++++++++++++++++ .../components/renault/test_binary_sensor.py | 25 +- tests/components/renault/test_button.py | 21 +- .../components/renault/test_device_tracker.py | 25 +- tests/components/renault/test_select.py | 25 +- tests/components/renault/test_sensor.py | 24 +- 10 files changed, 4747 insertions(+), 38 deletions(-) diff --git a/tests/components/renault/snapshots/test_binary_sensor.ambr b/tests/components/renault/snapshots/test_binary_sensor.ambr index ddeadd436cf9..dc10dd839f0c 100644 --- a/tests/components/renault/snapshots/test_binary_sensor.ambr +++ b/tests/components/renault/snapshots/test_binary_sensor.ambr @@ -1,4 +1,1166 @@ # serializer version: 1 +# name: test_binary_sensor_empty[captur_fuel] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777123', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Captur ii', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'XJB1SU', + 'via_device_id': None, + }), + ]) +# --- +# name: test_binary_sensor_empty[captur_fuel].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lock', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'lock_status', + 'unique_id': 'vf1aaaaa555777123_lock_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_hatch', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hatch', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'hatch_status', + 'unique_id': 'vf1aaaaa555777123_hatch_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_rear_left_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rear left door', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'rear_left_door_status', + 'unique_id': 'vf1aaaaa555777123_rear_left_door_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_rear_right_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rear right door', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'rear_right_door_status', + 'unique_id': 'vf1aaaaa555777123_rear_right_door_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_driver_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Driver door', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'driver_door_status', + 'unique_id': 'vf1aaaaa555777123_driver_door_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_passenger_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Passenger door', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'passenger_door_status', + 'unique_id': 'vf1aaaaa555777123_passenger_door_status', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_binary_sensor_empty[captur_fuel].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'lock', + 'friendly_name': 'REG-NUMBER Lock', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_lock', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-NUMBER Hatch', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_hatch', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-NUMBER Rear left door', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_rear_left_door', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-NUMBER Rear right door', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_rear_right_door', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-NUMBER Driver door', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_driver_door', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-NUMBER Passenger door', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_passenger_door', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + ]) +# --- +# name: test_binary_sensor_empty[captur_phev] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777123', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Captur ii', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'XJB1SU', + 'via_device_id': None, + }), + ]) +# --- +# name: test_binary_sensor_empty[captur_phev].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_plug', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Plug', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'plugged_in', + 'unique_id': 'vf1aaaaa555777123_plugged_in', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_charging', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'charging', + 'unique_id': 'vf1aaaaa555777123_charging', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lock', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'lock_status', + 'unique_id': 'vf1aaaaa555777123_lock_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_hatch', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hatch', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'hatch_status', + 'unique_id': 'vf1aaaaa555777123_hatch_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_rear_left_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rear left door', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'rear_left_door_status', + 'unique_id': 'vf1aaaaa555777123_rear_left_door_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_rear_right_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rear right door', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'rear_right_door_status', + 'unique_id': 'vf1aaaaa555777123_rear_right_door_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_driver_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Driver door', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'driver_door_status', + 'unique_id': 'vf1aaaaa555777123_driver_door_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_passenger_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Passenger door', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'passenger_door_status', + 'unique_id': 'vf1aaaaa555777123_passenger_door_status', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_binary_sensor_empty[captur_phev].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'plug', + 'friendly_name': 'REG-NUMBER Plug', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_plug', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery_charging', + 'friendly_name': 'REG-NUMBER Charging', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_charging', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'lock', + 'friendly_name': 'REG-NUMBER Lock', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_lock', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-NUMBER Hatch', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_hatch', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-NUMBER Rear left door', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_rear_left_door', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-NUMBER Rear right door', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_rear_right_door', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-NUMBER Driver door', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_driver_door', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-NUMBER Passenger door', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_passenger_door', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + ]) +# --- +# name: test_binary_sensor_empty[zoe_40] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777999', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Zoe', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'X101VE', + 'via_device_id': None, + }), + ]) +# --- +# name: test_binary_sensor_empty[zoe_40].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_plug', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Plug', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'plugged_in', + 'unique_id': 'vf1aaaaa555777999_plugged_in', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_charging', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'charging', + 'unique_id': 'vf1aaaaa555777999_charging', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_hvac', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:fan-off', + 'original_name': 'HVAC', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'hvac_status', + 'unique_id': 'vf1aaaaa555777999_hvac_status', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_binary_sensor_empty[zoe_40].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'plug', + 'friendly_name': 'REG-NUMBER Plug', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_plug', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery_charging', + 'friendly_name': 'REG-NUMBER Charging', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_charging', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER HVAC', + 'icon': 'mdi:fan-off', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_hvac', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + ]) +# --- +# name: test_binary_sensor_empty[zoe_50] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777999', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Zoe', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'X102VE', + 'via_device_id': None, + }), + ]) +# --- +# name: test_binary_sensor_empty[zoe_50].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_plug', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Plug', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'plugged_in', + 'unique_id': 'vf1aaaaa555777999_plugged_in', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_charging', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'charging', + 'unique_id': 'vf1aaaaa555777999_charging', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_hvac', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:fan-off', + 'original_name': 'HVAC', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'hvac_status', + 'unique_id': 'vf1aaaaa555777999_hvac_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lock', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'lock_status', + 'unique_id': 'vf1aaaaa555777999_lock_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_hatch', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hatch', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'hatch_status', + 'unique_id': 'vf1aaaaa555777999_hatch_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_rear_left_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rear left door', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'rear_left_door_status', + 'unique_id': 'vf1aaaaa555777999_rear_left_door_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_rear_right_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rear right door', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'rear_right_door_status', + 'unique_id': 'vf1aaaaa555777999_rear_right_door_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_driver_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Driver door', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'driver_door_status', + 'unique_id': 'vf1aaaaa555777999_driver_door_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_passenger_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Passenger door', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'passenger_door_status', + 'unique_id': 'vf1aaaaa555777999_passenger_door_status', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_binary_sensor_empty[zoe_50].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'plug', + 'friendly_name': 'REG-NUMBER Plug', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_plug', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery_charging', + 'friendly_name': 'REG-NUMBER Charging', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_charging', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER HVAC', + 'icon': 'mdi:fan-off', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_hvac', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'lock', + 'friendly_name': 'REG-NUMBER Lock', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_lock', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-NUMBER Hatch', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_hatch', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-NUMBER Rear left door', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_rear_left_door', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-NUMBER Rear right door', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_rear_right_door', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-NUMBER Driver door', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_driver_door', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-NUMBER Passenger door', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_passenger_door', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + ]) +# --- # name: test_binary_sensors[captur_fuel] list([ DeviceRegistryEntrySnapshot({ diff --git a/tests/components/renault/snapshots/test_button.ambr b/tests/components/renault/snapshots/test_button.ambr index 8a0c8af7aa79..8c56a3842ea6 100644 --- a/tests/components/renault/snapshots/test_button.ambr +++ b/tests/components/renault/snapshots/test_button.ambr @@ -1,4 +1,542 @@ # serializer version: 1 +# name: test_button_empty[captur_fuel] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777123', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Captur ii', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'XJB1SU', + 'via_device_id': None, + }), + ]) +# --- +# name: test_button_empty[captur_fuel].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_number_start_air_conditioner', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:air-conditioner', + 'original_name': 'Start air conditioner', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'start_air_conditioner', + 'unique_id': 'vf1aaaaa555777123_start_air_conditioner', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_button_empty[captur_fuel].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Start air conditioner', + 'icon': 'mdi:air-conditioner', + }), + 'context': , + 'entity_id': 'button.reg_number_start_air_conditioner', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + ]) +# --- +# name: test_button_empty[captur_phev] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777123', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Captur ii', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'XJB1SU', + 'via_device_id': None, + }), + ]) +# --- +# name: test_button_empty[captur_phev].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_number_start_air_conditioner', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:air-conditioner', + 'original_name': 'Start air conditioner', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'start_air_conditioner', + 'unique_id': 'vf1aaaaa555777123_start_air_conditioner', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_number_start_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:ev-station', + 'original_name': 'Start charge', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'start_charge', + 'unique_id': 'vf1aaaaa555777123_start_charge', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_number_stop_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:ev-station', + 'original_name': 'Stop charge', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'stop_charge', + 'unique_id': 'vf1aaaaa555777123_stop_charge', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_button_empty[captur_phev].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Start air conditioner', + 'icon': 'mdi:air-conditioner', + }), + 'context': , + 'entity_id': 'button.reg_number_start_air_conditioner', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Start charge', + 'icon': 'mdi:ev-station', + }), + 'context': , + 'entity_id': 'button.reg_number_start_charge', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Stop charge', + 'icon': 'mdi:ev-station', + }), + 'context': , + 'entity_id': 'button.reg_number_stop_charge', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + ]) +# --- +# name: test_button_empty[zoe_40] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777999', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Zoe', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'X101VE', + 'via_device_id': None, + }), + ]) +# --- +# name: test_button_empty[zoe_40].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_number_start_air_conditioner', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:air-conditioner', + 'original_name': 'Start air conditioner', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'start_air_conditioner', + 'unique_id': 'vf1aaaaa555777999_start_air_conditioner', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_number_start_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:ev-station', + 'original_name': 'Start charge', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'start_charge', + 'unique_id': 'vf1aaaaa555777999_start_charge', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_number_stop_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:ev-station', + 'original_name': 'Stop charge', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'stop_charge', + 'unique_id': 'vf1aaaaa555777999_stop_charge', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_button_empty[zoe_40].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Start air conditioner', + 'icon': 'mdi:air-conditioner', + }), + 'context': , + 'entity_id': 'button.reg_number_start_air_conditioner', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Start charge', + 'icon': 'mdi:ev-station', + }), + 'context': , + 'entity_id': 'button.reg_number_start_charge', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Stop charge', + 'icon': 'mdi:ev-station', + }), + 'context': , + 'entity_id': 'button.reg_number_stop_charge', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + ]) +# --- +# name: test_button_empty[zoe_50] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777999', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Zoe', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'X102VE', + 'via_device_id': None, + }), + ]) +# --- +# name: test_button_empty[zoe_50].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_number_start_air_conditioner', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:air-conditioner', + 'original_name': 'Start air conditioner', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'start_air_conditioner', + 'unique_id': 'vf1aaaaa555777999_start_air_conditioner', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_number_start_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:ev-station', + 'original_name': 'Start charge', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'start_charge', + 'unique_id': 'vf1aaaaa555777999_start_charge', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_number_stop_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:ev-station', + 'original_name': 'Stop charge', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'stop_charge', + 'unique_id': 'vf1aaaaa555777999_stop_charge', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_button_empty[zoe_50].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Start air conditioner', + 'icon': 'mdi:air-conditioner', + }), + 'context': , + 'entity_id': 'button.reg_number_start_air_conditioner', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Start charge', + 'icon': 'mdi:ev-station', + }), + 'context': , + 'entity_id': 'button.reg_number_start_charge', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Stop charge', + 'icon': 'mdi:ev-station', + }), + 'context': , + 'entity_id': 'button.reg_number_stop_charge', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + ]) +# --- # name: test_buttons[captur_fuel] list([ DeviceRegistryEntrySnapshot({ diff --git a/tests/components/renault/snapshots/test_device_tracker.ambr b/tests/components/renault/snapshots/test_device_tracker.ambr index 571ef2203e10..474791791d99 100644 --- a/tests/components/renault/snapshots/test_device_tracker.ambr +++ b/tests/components/renault/snapshots/test_device_tracker.ambr @@ -1,4 +1,272 @@ # serializer version: 1 +# name: test_device_tracker_empty[captur_fuel] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777123', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Captur ii', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'XJB1SU', + 'via_device_id': None, + }), + ]) +# --- +# name: test_device_tracker_empty[captur_fuel].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'device_tracker', + 'entity_category': , + 'entity_id': 'device_tracker.reg_number_location', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:car', + 'original_name': 'Location', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'location', + 'unique_id': 'vf1aaaaa555777123_location', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_device_tracker_empty[captur_fuel].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Location', + 'icon': 'mdi:car', + 'source_type': , + }), + 'context': , + 'entity_id': 'device_tracker.reg_number_location', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + ]) +# --- +# name: test_device_tracker_empty[captur_phev] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777123', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Captur ii', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'XJB1SU', + 'via_device_id': None, + }), + ]) +# --- +# name: test_device_tracker_empty[captur_phev].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'device_tracker', + 'entity_category': , + 'entity_id': 'device_tracker.reg_number_location', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:car', + 'original_name': 'Location', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'location', + 'unique_id': 'vf1aaaaa555777123_location', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_device_tracker_empty[captur_phev].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Location', + 'icon': 'mdi:car', + 'source_type': , + }), + 'context': , + 'entity_id': 'device_tracker.reg_number_location', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + ]) +# --- +# name: test_device_tracker_empty[zoe_40] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777999', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Zoe', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'X101VE', + 'via_device_id': None, + }), + ]) +# --- +# name: test_device_tracker_empty[zoe_40].1 + list([ + ]) +# --- +# name: test_device_tracker_empty[zoe_40].2 + list([ + ]) +# --- +# name: test_device_tracker_empty[zoe_50] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777999', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Zoe', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'X102VE', + 'via_device_id': None, + }), + ]) +# --- +# name: test_device_tracker_empty[zoe_50].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'device_tracker', + 'entity_category': , + 'entity_id': 'device_tracker.reg_number_location', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:car', + 'original_name': 'Location', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'location', + 'unique_id': 'vf1aaaaa555777999_location', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_device_tracker_empty[zoe_50].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Location', + 'icon': 'mdi:car', + 'source_type': , + }), + 'context': , + 'entity_id': 'device_tracker.reg_number_location', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + ]) +# --- # name: test_device_trackers[captur_fuel] list([ DeviceRegistryEntrySnapshot({ diff --git a/tests/components/renault/snapshots/test_select.ambr b/tests/components/renault/snapshots/test_select.ambr index 4c7103ebe5b6..c5bbc6b20024 100644 --- a/tests/components/renault/snapshots/test_select.ambr +++ b/tests/components/renault/snapshots/test_select.ambr @@ -1,4 +1,302 @@ # serializer version: 1 +# name: test_select_empty[captur_fuel] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777123', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Captur ii', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'XJB1SU', + 'via_device_id': None, + }), + ]) +# --- +# name: test_select_empty[captur_fuel].1 + list([ + ]) +# --- +# name: test_select_empty[captur_fuel].2 + list([ + ]) +# --- +# name: test_select_empty[captur_phev] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777123', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Captur ii', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'XJB1SU', + 'via_device_id': None, + }), + ]) +# --- +# name: test_select_empty[captur_phev].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'always', + 'always_charging', + 'schedule_mode', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.reg_number_charge_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:calendar-remove', + 'original_name': 'Charge mode', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'charge_mode', + 'unique_id': 'vf1aaaaa555777123_charge_mode', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_select_empty[captur_phev].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Charge mode', + 'icon': 'mdi:calendar-remove', + 'options': list([ + 'always', + 'always_charging', + 'schedule_mode', + ]), + }), + 'context': , + 'entity_id': 'select.reg_number_charge_mode', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + ]) +# --- +# name: test_select_empty[zoe_40] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777999', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Zoe', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'X101VE', + 'via_device_id': None, + }), + ]) +# --- +# name: test_select_empty[zoe_40].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'always', + 'always_charging', + 'schedule_mode', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.reg_number_charge_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:calendar-remove', + 'original_name': 'Charge mode', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'charge_mode', + 'unique_id': 'vf1aaaaa555777999_charge_mode', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_select_empty[zoe_40].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Charge mode', + 'icon': 'mdi:calendar-remove', + 'options': list([ + 'always', + 'always_charging', + 'schedule_mode', + ]), + }), + 'context': , + 'entity_id': 'select.reg_number_charge_mode', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + ]) +# --- +# name: test_select_empty[zoe_50] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777999', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Zoe', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'X102VE', + 'via_device_id': None, + }), + ]) +# --- +# name: test_select_empty[zoe_50].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'always', + 'always_charging', + 'schedule_mode', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.reg_number_charge_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:calendar-remove', + 'original_name': 'Charge mode', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'charge_mode', + 'unique_id': 'vf1aaaaa555777999_charge_mode', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_select_empty[zoe_50].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Charge mode', + 'icon': 'mdi:calendar-remove', + 'options': list([ + 'always', + 'always_charging', + 'schedule_mode', + ]), + }), + 'context': , + 'entity_id': 'select.reg_number_charge_mode', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + ]) +# --- # name: test_selects[captur_fuel] list([ DeviceRegistryEntrySnapshot({ diff --git a/tests/components/renault/snapshots/test_sensor.ambr b/tests/components/renault/snapshots/test_sensor.ambr index 2bf8c0c17bfe..72f9201b7a42 100644 --- a/tests/components/renault/snapshots/test_sensor.ambr +++ b/tests/components/renault/snapshots/test_sensor.ambr @@ -1,4 +1,2403 @@ # serializer version: 1 +# name: test_sensor_empty[captur_fuel] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777123', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Captur ii', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'XJB1SU', + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensor_empty[captur_fuel].1 + list([ + 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.reg_number_mileage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:sign-direction', + 'original_name': 'Mileage', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'mileage', + 'unique_id': 'vf1aaaaa555777123_mileage', + 'unit_of_measurement': , + }), + 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.reg_number_fuel_autonomy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:gas-station', + 'original_name': 'Fuel autonomy', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'fuel_autonomy', + 'unique_id': 'vf1aaaaa555777123_fuel_autonomy', + 'unit_of_measurement': , + }), + 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.reg_number_fuel_quantity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:fuel', + 'original_name': 'Fuel quantity', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'fuel_quantity', + 'unique_id': 'vf1aaaaa555777123_fuel_quantity', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_last_location_activity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last location activity', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'location_last_activity', + 'unique_id': 'vf1aaaaa555777123_location_last_activity', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_remote_engine_start', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Remote engine start', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'res_state', + 'unique_id': 'vf1aaaaa555777123_res_state', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_remote_engine_start_code', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Remote engine start code', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'res_state_code', + 'unique_id': 'vf1aaaaa555777123_res_state_code', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_sensor_empty[captur_fuel].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-NUMBER Mileage', + 'icon': 'mdi:sign-direction', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_mileage', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-NUMBER Fuel autonomy', + 'icon': 'mdi:gas-station', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_fuel_autonomy', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume', + 'friendly_name': 'REG-NUMBER Fuel quantity', + 'icon': 'mdi:fuel', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_fuel_quantity', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'REG-NUMBER Last location activity', + }), + 'context': , + 'entity_id': 'sensor.reg_number_last_location_activity', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Remote engine start', + }), + 'context': , + 'entity_id': 'sensor.reg_number_remote_engine_start', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Remote engine start code', + }), + 'context': , + 'entity_id': 'sensor.reg_number_remote_engine_start_code', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + ]) +# --- +# name: test_sensor_empty[captur_phev] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777123', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Captur ii', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'XJB1SU', + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensor_empty[captur_phev].1 + list([ + 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.reg_number_battery_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery level', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'battery_level', + 'unique_id': 'vf1aaaaa555777123_battery_level', + 'unit_of_measurement': '%', + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'not_in_charge', + 'waiting_for_a_planned_charge', + 'charge_ended', + 'waiting_for_current_charge', + 'energy_flap_opened', + 'charge_in_progress', + 'charge_error', + 'unavailable', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_charge_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:flash-off', + 'original_name': 'Charge state', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'charge_state', + 'unique_id': 'vf1aaaaa555777123_charge_state', + 'unit_of_measurement': None, + }), + 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.reg_number_charging_remaining_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:timer', + 'original_name': 'Charging remaining time', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'charging_remaining_time', + 'unique_id': 'vf1aaaaa555777123_charging_remaining_time', + 'unit_of_measurement': , + }), + 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.reg_number_admissible_charging_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Admissible charging power', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'admissible_charging_power', + 'unique_id': 'vf1aaaaa555777123_charging_power', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'unplugged', + 'plugged', + 'plug_error', + 'plug_unknown', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_plug_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:power-plug-off', + 'original_name': 'Plug state', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'plug_state', + 'unique_id': 'vf1aaaaa555777123_plug_state', + 'unit_of_measurement': None, + }), + 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.reg_number_battery_autonomy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:ev-station', + 'original_name': 'Battery autonomy', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'battery_autonomy', + 'unique_id': 'vf1aaaaa555777123_battery_autonomy', + 'unit_of_measurement': , + }), + 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.reg_number_battery_available_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery available energy', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'battery_available_energy', + 'unique_id': 'vf1aaaaa555777123_battery_available_energy', + 'unit_of_measurement': , + }), + 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.reg_number_battery_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery temperature', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'battery_temperature', + 'unique_id': 'vf1aaaaa555777123_battery_temperature', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_last_battery_activity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last battery activity', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'battery_last_activity', + 'unique_id': 'vf1aaaaa555777123_battery_last_activity', + 'unit_of_measurement': None, + }), + 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.reg_number_mileage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:sign-direction', + 'original_name': 'Mileage', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'mileage', + 'unique_id': 'vf1aaaaa555777123_mileage', + 'unit_of_measurement': , + }), + 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.reg_number_fuel_autonomy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:gas-station', + 'original_name': 'Fuel autonomy', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'fuel_autonomy', + 'unique_id': 'vf1aaaaa555777123_fuel_autonomy', + 'unit_of_measurement': , + }), + 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.reg_number_fuel_quantity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:fuel', + 'original_name': 'Fuel quantity', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'fuel_quantity', + 'unique_id': 'vf1aaaaa555777123_fuel_quantity', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_last_location_activity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last location activity', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'location_last_activity', + 'unique_id': 'vf1aaaaa555777123_location_last_activity', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_remote_engine_start', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Remote engine start', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'res_state', + 'unique_id': 'vf1aaaaa555777123_res_state', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_remote_engine_start_code', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Remote engine start code', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'res_state_code', + 'unique_id': 'vf1aaaaa555777123_res_state_code', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_sensor_empty[captur_phev].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'REG-NUMBER Battery level', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.reg_number_battery_level', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'REG-NUMBER Charge state', + 'icon': 'mdi:flash-off', + 'options': list([ + 'not_in_charge', + 'waiting_for_a_planned_charge', + 'charge_ended', + 'waiting_for_current_charge', + 'energy_flap_opened', + 'charge_in_progress', + 'charge_error', + 'unavailable', + ]), + }), + 'context': , + 'entity_id': 'sensor.reg_number_charge_state', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Charging remaining time', + 'icon': 'mdi:timer', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_charging_remaining_time', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'REG-NUMBER Admissible charging power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_admissible_charging_power', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'REG-NUMBER Plug state', + 'icon': 'mdi:power-plug-off', + 'options': list([ + 'unplugged', + 'plugged', + 'plug_error', + 'plug_unknown', + ]), + }), + 'context': , + 'entity_id': 'sensor.reg_number_plug_state', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-NUMBER Battery autonomy', + 'icon': 'mdi:ev-station', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_battery_autonomy', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'REG-NUMBER Battery available energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_battery_available_energy', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'REG-NUMBER Battery temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_battery_temperature', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'REG-NUMBER Last battery activity', + }), + 'context': , + 'entity_id': 'sensor.reg_number_last_battery_activity', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-NUMBER Mileage', + 'icon': 'mdi:sign-direction', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_mileage', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-NUMBER Fuel autonomy', + 'icon': 'mdi:gas-station', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_fuel_autonomy', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume', + 'friendly_name': 'REG-NUMBER Fuel quantity', + 'icon': 'mdi:fuel', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_fuel_quantity', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'REG-NUMBER Last location activity', + }), + 'context': , + 'entity_id': 'sensor.reg_number_last_location_activity', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Remote engine start', + }), + 'context': , + 'entity_id': 'sensor.reg_number_remote_engine_start', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Remote engine start code', + }), + 'context': , + 'entity_id': 'sensor.reg_number_remote_engine_start_code', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + ]) +# --- +# name: test_sensor_empty[zoe_40] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777999', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Zoe', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'X101VE', + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensor_empty[zoe_40].1 + list([ + 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.reg_number_battery_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery level', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'battery_level', + 'unique_id': 'vf1aaaaa555777999_battery_level', + 'unit_of_measurement': '%', + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'not_in_charge', + 'waiting_for_a_planned_charge', + 'charge_ended', + 'waiting_for_current_charge', + 'energy_flap_opened', + 'charge_in_progress', + 'charge_error', + 'unavailable', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_charge_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:flash-off', + 'original_name': 'Charge state', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'charge_state', + 'unique_id': 'vf1aaaaa555777999_charge_state', + 'unit_of_measurement': None, + }), + 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.reg_number_charging_remaining_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:timer', + 'original_name': 'Charging remaining time', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'charging_remaining_time', + 'unique_id': 'vf1aaaaa555777999_charging_remaining_time', + 'unit_of_measurement': , + }), + 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.reg_number_charging_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging power', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'charging_power', + 'unique_id': 'vf1aaaaa555777999_charging_power', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'unplugged', + 'plugged', + 'plug_error', + 'plug_unknown', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_plug_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:power-plug-off', + 'original_name': 'Plug state', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'plug_state', + 'unique_id': 'vf1aaaaa555777999_plug_state', + 'unit_of_measurement': None, + }), + 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.reg_number_battery_autonomy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:ev-station', + 'original_name': 'Battery autonomy', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'battery_autonomy', + 'unique_id': 'vf1aaaaa555777999_battery_autonomy', + 'unit_of_measurement': , + }), + 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.reg_number_battery_available_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery available energy', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'battery_available_energy', + 'unique_id': 'vf1aaaaa555777999_battery_available_energy', + 'unit_of_measurement': , + }), + 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.reg_number_battery_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery temperature', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'battery_temperature', + 'unique_id': 'vf1aaaaa555777999_battery_temperature', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_last_battery_activity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last battery activity', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'battery_last_activity', + 'unique_id': 'vf1aaaaa555777999_battery_last_activity', + 'unit_of_measurement': None, + }), + 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.reg_number_mileage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:sign-direction', + 'original_name': 'Mileage', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'mileage', + 'unique_id': 'vf1aaaaa555777999_mileage', + 'unit_of_measurement': , + }), + 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.reg_number_outside_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Outside temperature', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'outside_temperature', + 'unique_id': 'vf1aaaaa555777999_outside_temperature', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_hvac_soc_threshold', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HVAC SoC threshold', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'hvac_soc_threshold', + 'unique_id': 'vf1aaaaa555777999_hvac_soc_threshold', + 'unit_of_measurement': '%', + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_last_hvac_activity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last HVAC activity', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'hvac_last_activity', + 'unique_id': 'vf1aaaaa555777999_hvac_last_activity', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_remote_engine_start', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Remote engine start', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'res_state', + 'unique_id': 'vf1aaaaa555777999_res_state', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_remote_engine_start_code', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Remote engine start code', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'res_state_code', + 'unique_id': 'vf1aaaaa555777999_res_state_code', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_sensor_empty[zoe_40].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'REG-NUMBER Battery level', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.reg_number_battery_level', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'REG-NUMBER Charge state', + 'icon': 'mdi:flash-off', + 'options': list([ + 'not_in_charge', + 'waiting_for_a_planned_charge', + 'charge_ended', + 'waiting_for_current_charge', + 'energy_flap_opened', + 'charge_in_progress', + 'charge_error', + 'unavailable', + ]), + }), + 'context': , + 'entity_id': 'sensor.reg_number_charge_state', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Charging remaining time', + 'icon': 'mdi:timer', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_charging_remaining_time', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'REG-NUMBER Charging power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_charging_power', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'REG-NUMBER Plug state', + 'icon': 'mdi:power-plug-off', + 'options': list([ + 'unplugged', + 'plugged', + 'plug_error', + 'plug_unknown', + ]), + }), + 'context': , + 'entity_id': 'sensor.reg_number_plug_state', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-NUMBER Battery autonomy', + 'icon': 'mdi:ev-station', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_battery_autonomy', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'REG-NUMBER Battery available energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_battery_available_energy', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'REG-NUMBER Battery temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_battery_temperature', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'REG-NUMBER Last battery activity', + }), + 'context': , + 'entity_id': 'sensor.reg_number_last_battery_activity', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-NUMBER Mileage', + 'icon': 'mdi:sign-direction', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_mileage', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'REG-NUMBER Outside temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_outside_temperature', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER HVAC SoC threshold', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.reg_number_hvac_soc_threshold', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'REG-NUMBER Last HVAC activity', + }), + 'context': , + 'entity_id': 'sensor.reg_number_last_hvac_activity', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Remote engine start', + }), + 'context': , + 'entity_id': 'sensor.reg_number_remote_engine_start', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Remote engine start code', + }), + 'context': , + 'entity_id': 'sensor.reg_number_remote_engine_start_code', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + ]) +# --- +# name: test_sensor_empty[zoe_50] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777999', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Zoe', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'X102VE', + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensor_empty[zoe_50].1 + list([ + 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.reg_number_battery_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery level', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'battery_level', + 'unique_id': 'vf1aaaaa555777999_battery_level', + 'unit_of_measurement': '%', + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'not_in_charge', + 'waiting_for_a_planned_charge', + 'charge_ended', + 'waiting_for_current_charge', + 'energy_flap_opened', + 'charge_in_progress', + 'charge_error', + 'unavailable', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_charge_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:flash-off', + 'original_name': 'Charge state', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'charge_state', + 'unique_id': 'vf1aaaaa555777999_charge_state', + 'unit_of_measurement': None, + }), + 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.reg_number_charging_remaining_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:timer', + 'original_name': 'Charging remaining time', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'charging_remaining_time', + 'unique_id': 'vf1aaaaa555777999_charging_remaining_time', + 'unit_of_measurement': , + }), + 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.reg_number_admissible_charging_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Admissible charging power', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'admissible_charging_power', + 'unique_id': 'vf1aaaaa555777999_charging_power', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'unplugged', + 'plugged', + 'plug_error', + 'plug_unknown', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_plug_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:power-plug-off', + 'original_name': 'Plug state', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'plug_state', + 'unique_id': 'vf1aaaaa555777999_plug_state', + 'unit_of_measurement': None, + }), + 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.reg_number_battery_autonomy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:ev-station', + 'original_name': 'Battery autonomy', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'battery_autonomy', + 'unique_id': 'vf1aaaaa555777999_battery_autonomy', + 'unit_of_measurement': , + }), + 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.reg_number_battery_available_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery available energy', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'battery_available_energy', + 'unique_id': 'vf1aaaaa555777999_battery_available_energy', + 'unit_of_measurement': , + }), + 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.reg_number_battery_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery temperature', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'battery_temperature', + 'unique_id': 'vf1aaaaa555777999_battery_temperature', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_last_battery_activity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last battery activity', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'battery_last_activity', + 'unique_id': 'vf1aaaaa555777999_battery_last_activity', + 'unit_of_measurement': None, + }), + 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.reg_number_mileage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:sign-direction', + 'original_name': 'Mileage', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'mileage', + 'unique_id': 'vf1aaaaa555777999_mileage', + 'unit_of_measurement': , + }), + 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.reg_number_outside_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Outside temperature', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'outside_temperature', + 'unique_id': 'vf1aaaaa555777999_outside_temperature', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_hvac_soc_threshold', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HVAC SoC threshold', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'hvac_soc_threshold', + 'unique_id': 'vf1aaaaa555777999_hvac_soc_threshold', + 'unit_of_measurement': '%', + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_last_hvac_activity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last HVAC activity', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'hvac_last_activity', + 'unique_id': 'vf1aaaaa555777999_hvac_last_activity', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_last_location_activity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last location activity', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'location_last_activity', + 'unique_id': 'vf1aaaaa555777999_location_last_activity', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_remote_engine_start', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Remote engine start', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'res_state', + 'unique_id': 'vf1aaaaa555777999_res_state', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_remote_engine_start_code', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Remote engine start code', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'res_state_code', + 'unique_id': 'vf1aaaaa555777999_res_state_code', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_sensor_empty[zoe_50].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'REG-NUMBER Battery level', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.reg_number_battery_level', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'REG-NUMBER Charge state', + 'icon': 'mdi:flash-off', + 'options': list([ + 'not_in_charge', + 'waiting_for_a_planned_charge', + 'charge_ended', + 'waiting_for_current_charge', + 'energy_flap_opened', + 'charge_in_progress', + 'charge_error', + 'unavailable', + ]), + }), + 'context': , + 'entity_id': 'sensor.reg_number_charge_state', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Charging remaining time', + 'icon': 'mdi:timer', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_charging_remaining_time', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'REG-NUMBER Admissible charging power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_admissible_charging_power', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'REG-NUMBER Plug state', + 'icon': 'mdi:power-plug-off', + 'options': list([ + 'unplugged', + 'plugged', + 'plug_error', + 'plug_unknown', + ]), + }), + 'context': , + 'entity_id': 'sensor.reg_number_plug_state', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-NUMBER Battery autonomy', + 'icon': 'mdi:ev-station', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_battery_autonomy', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'REG-NUMBER Battery available energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_battery_available_energy', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'REG-NUMBER Battery temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_battery_temperature', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'REG-NUMBER Last battery activity', + }), + 'context': , + 'entity_id': 'sensor.reg_number_last_battery_activity', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-NUMBER Mileage', + 'icon': 'mdi:sign-direction', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_mileage', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'REG-NUMBER Outside temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_outside_temperature', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER HVAC SoC threshold', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.reg_number_hvac_soc_threshold', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'REG-NUMBER Last HVAC activity', + }), + 'context': , + 'entity_id': 'sensor.reg_number_last_hvac_activity', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'REG-NUMBER Last location activity', + }), + 'context': , + 'entity_id': 'sensor.reg_number_last_location_activity', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Remote engine start', + }), + 'context': , + 'entity_id': 'sensor.reg_number_remote_engine_start', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Remote engine start code', + }), + 'context': , + 'entity_id': 'sensor.reg_number_remote_engine_start_code', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + ]) +# --- # name: test_sensors[captur_fuel] list([ DeviceRegistryEntrySnapshot({ diff --git a/tests/components/renault/test_binary_sensor.py b/tests/components/renault/test_binary_sensor.py index 320c5cc30d59..f1e3511dc2c5 100644 --- a/tests/components/renault/test_binary_sensor.py +++ b/tests/components/renault/test_binary_sensor.py @@ -6,11 +6,11 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_UNKNOWN, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from . import check_device_registry, check_entities_no_data, check_entities_unavailable +from . import check_device_registry, check_entities_unavailable from .const import MOCK_VEHICLES pytestmark = pytest.mark.usefixtures("patch_renault_account", "patch_get_vehicles") @@ -56,20 +56,29 @@ async def test_binary_sensors( async def test_binary_sensor_empty( hass: HomeAssistant, config_entry: ConfigEntry, - vehicle_type: str, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test for Renault binary sensors with empty data from Renault.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - mock_vehicle = MOCK_VEHICLES[vehicle_type] - check_device_registry(device_registry, mock_vehicle["expected_device"]) + # Ensure devices are correctly registered + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + assert device_entries == snapshot - expected_entities = mock_vehicle[Platform.BINARY_SENSOR] - assert len(entity_registry.entities) == len(expected_entities) - check_entities_no_data(hass, entity_registry, expected_entities, STATE_UNKNOWN) + # Ensure entities are correctly registered + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + assert entity_entries == snapshot + + # Ensure entity states are correct + states = [hass.states.get(ent.entity_id) for ent in entity_entries] + assert states == snapshot @pytest.mark.usefixtures("fixtures_with_invalid_upstream_exception") diff --git a/tests/components/renault/test_button.py b/tests/components/renault/test_button.py index 576e3c05c2ae..47a411ce791b 100644 --- a/tests/components/renault/test_button.py +++ b/tests/components/renault/test_button.py @@ -60,20 +60,29 @@ async def test_buttons( async def test_button_empty( hass: HomeAssistant, config_entry: ConfigEntry, - vehicle_type: str, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test for Renault device trackers with empty data from Renault.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - mock_vehicle = MOCK_VEHICLES[vehicle_type] - check_device_registry(device_registry, mock_vehicle["expected_device"]) + # Ensure devices are correctly registered + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + assert device_entries == snapshot - expected_entities = mock_vehicle[Platform.BUTTON] - assert len(entity_registry.entities) == len(expected_entities) - check_entities_no_data(hass, entity_registry, expected_entities, STATE_UNKNOWN) + # Ensure entities are correctly registered + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + assert entity_entries == snapshot + + # Ensure entity states are correct + states = [hass.states.get(ent.entity_id) for ent in entity_entries] + assert states == snapshot @pytest.mark.usefixtures("fixtures_with_invalid_upstream_exception") diff --git a/tests/components/renault/test_device_tracker.py b/tests/components/renault/test_device_tracker.py index 910524422ca9..a551d2df986a 100644 --- a/tests/components/renault/test_device_tracker.py +++ b/tests/components/renault/test_device_tracker.py @@ -6,11 +6,11 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_UNKNOWN, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from . import check_device_registry, check_entities_no_data, check_entities_unavailable +from . import check_device_registry, check_entities_unavailable from .const import MOCK_VEHICLES pytestmark = pytest.mark.usefixtures("patch_renault_account", "patch_get_vehicles") @@ -56,20 +56,29 @@ async def test_device_trackers( async def test_device_tracker_empty( hass: HomeAssistant, config_entry: ConfigEntry, - vehicle_type: str, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test for Renault device trackers with empty data from Renault.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - mock_vehicle = MOCK_VEHICLES[vehicle_type] - check_device_registry(device_registry, mock_vehicle["expected_device"]) + # Ensure devices are correctly registered + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + assert device_entries == snapshot - expected_entities = mock_vehicle[Platform.DEVICE_TRACKER] - assert len(entity_registry.entities) == len(expected_entities) - check_entities_no_data(hass, entity_registry, expected_entities, STATE_UNKNOWN) + # Ensure entities are correctly registered + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + assert entity_entries == snapshot + + # Ensure entity states are correct + states = [hass.states.get(ent.entity_id) for ent in entity_entries] + assert states == snapshot @pytest.mark.usefixtures("fixtures_with_invalid_upstream_exception") diff --git a/tests/components/renault/test_select.py b/tests/components/renault/test_select.py index 9b5a6b014528..f170dec6c4af 100644 --- a/tests/components/renault/test_select.py +++ b/tests/components/renault/test_select.py @@ -12,11 +12,11 @@ from homeassistant.components.select import ( SERVICE_SELECT_OPTION, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from . import check_device_registry, check_entities_no_data, check_entities_unavailable +from . import check_device_registry, check_entities_unavailable from .const import MOCK_VEHICLES from tests.common import load_fixture @@ -64,20 +64,29 @@ async def test_selects( async def test_select_empty( hass: HomeAssistant, config_entry: ConfigEntry, - vehicle_type: str, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test for Renault selects with empty data from Renault.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - mock_vehicle = MOCK_VEHICLES[vehicle_type] - check_device_registry(device_registry, mock_vehicle["expected_device"]) + # Ensure devices are correctly registered + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + assert device_entries == snapshot - expected_entities = mock_vehicle[Platform.SELECT] - assert len(entity_registry.entities) == len(expected_entities) - check_entities_no_data(hass, entity_registry, expected_entities, STATE_UNKNOWN) + # Ensure entities are correctly registered + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + assert entity_entries == snapshot + + # Ensure entity states are correct + states = [hass.states.get(ent.entity_id) for ent in entity_entries] + assert states == snapshot @pytest.mark.usefixtures("fixtures_with_invalid_upstream_exception") diff --git a/tests/components/renault/test_sensor.py b/tests/components/renault/test_sensor.py index e0462834ae47..fb61f31ec44d 100644 --- a/tests/components/renault/test_sensor.py +++ b/tests/components/renault/test_sensor.py @@ -6,11 +6,11 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_UNKNOWN, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from . import check_device_registry, check_entities_no_data, check_entities_unavailable +from . import check_device_registry, check_entities_unavailable from .const import MOCK_VEHICLES pytestmark = pytest.mark.usefixtures("patch_renault_account", "patch_get_vehicles") @@ -62,21 +62,29 @@ async def test_sensors( async def test_sensor_empty( hass: HomeAssistant, config_entry: ConfigEntry, - vehicle_type: str, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test for Renault sensors with empty data from Renault.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - mock_vehicle = MOCK_VEHICLES[vehicle_type] - check_device_registry(device_registry, mock_vehicle["expected_device"]) + # Ensure devices are correctly registered + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + assert device_entries == snapshot - expected_entities = mock_vehicle[Platform.SENSOR] - assert len(entity_registry.entities) == len(expected_entities) + # Ensure entities are correctly registered + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + assert entity_entries == snapshot - check_entities_no_data(hass, entity_registry, expected_entities, STATE_UNKNOWN) + # Ensure entity states are correct + states = [hass.states.get(ent.entity_id) for ent in entity_entries] + assert states == snapshot @pytest.mark.usefixtures( From 2906caf4d122bb059044889f0ba624b483625f5a Mon Sep 17 00:00:00 2001 From: Mike Degatano Date: Wed, 24 May 2023 03:42:30 -0400 Subject: [PATCH 497/822] Fix reference string in data disk repair (#93220) * Fix reference string in data disk repair * Use backticks --- homeassistant/components/hassio/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json index 078aac39a5ba..36cd61a0bb4d 100644 --- a/homeassistant/components/hassio/strings.json +++ b/homeassistant/components/hassio/strings.json @@ -22,7 +22,7 @@ "fix_flow": { "step": { "system_rename_data_disk": { - "description": "'{reference}' is a filesystem with the name 'hassos-data' and is not the active data disk. This can cause Home Assistant to choose the wrong data disk at system reboot.\n\nUse the fix option to rename the filesystem to prevent this. Alternatively you can move the data disk to the drive (overwriting its contents) or remove the drive from the system." + "description": "`{reference}` is a filesystem with the name hassos-data and is not the active data disk. This can cause Home Assistant to choose the wrong data disk at system reboot.\n\nUse the fix option to rename the filesystem to prevent this. Alternatively you can move the data disk to the drive (overwriting its contents) or remove the drive from the system." } }, "abort": { From a310b5c1c3b2bb745b417d8fce808f26eaed4696 Mon Sep 17 00:00:00 2001 From: Ludy Date: Wed, 24 May 2023 09:42:44 +0200 Subject: [PATCH 498/822] remove template deprecated function `device_trigger.py` (#93419) remove template deprecated fun `device_trigger.py` --- .../device_trigger/integration/device_trigger.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/script/scaffold/templates/device_trigger/integration/device_trigger.py b/script/scaffold/templates/device_trigger/integration/device_trigger.py index 1fd8810bd88b..4179bf5248a4 100644 --- a/script/scaffold/templates/device_trigger/integration/device_trigger.py +++ b/script/scaffold/templates/device_trigger/integration/device_trigger.py @@ -5,10 +5,6 @@ from typing import Any import voluptuous as vol -from homeassistant.components.automation import ( - AutomationActionType, - AutomationTriggerInfo, -) from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA from homeassistant.components.homeassistant.triggers import state as state_trigger from homeassistant.const import ( @@ -22,6 +18,7 @@ from homeassistant.const import ( ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.helpers import config_validation as cv, entity_registry as er +from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType from . import DOMAIN @@ -73,8 +70,8 @@ async def async_get_triggers( async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, - action: AutomationActionType, - automation_info: AutomationTriggerInfo, + action: TriggerActionType, + trigger_info: TriggerInfo, ) -> CALLBACK_TYPE: """Attach a trigger.""" # TODO Implement your own logic to attach triggers. @@ -92,5 +89,5 @@ async def async_attach_trigger( } state_config = await state_trigger.async_validate_trigger_config(hass, state_config) return await state_trigger.async_attach_trigger( - hass, state_config, action, automation_info, platform_type="device" + hass, state_config, action, trigger_info, platform_type="device" ) From 7bc9e1ae9fc144d596df2572c28b232beab8ec75 Mon Sep 17 00:00:00 2001 From: Jafar Atili Date: Wed, 24 May 2023 10:44:36 +0300 Subject: [PATCH 499/822] Bump PySwitchbee to 1.8.0 (#92348) * fix * fixes * bump pyswitchbee * bump pyswitchbee * bump pyswitchbee * fix * bump pyswitchbee * Apply suggestions from code review --------- Co-authored-by: Jafar Atili Co-authored-by: Erik Montnemery --- .../components/switchbee/__init__.py | 108 ++++++++++++++++-- homeassistant/components/switchbee/climate.py | 2 +- .../components/switchbee/config_flow.py | 9 +- .../components/switchbee/coordinator.py | 9 +- homeassistant/components/switchbee/entity.py | 29 +---- .../components/switchbee/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../components/switchbee/test_config_flow.py | 9 +- 9 files changed, 126 insertions(+), 46 deletions(-) diff --git a/homeassistant/components/switchbee/__init__.py b/homeassistant/components/switchbee/__init__.py index 352f191588ac..dee1fe5cd8f5 100644 --- a/homeassistant/components/switchbee/__init__.py +++ b/homeassistant/components/switchbee/__init__.py @@ -2,18 +2,27 @@ from __future__ import annotations +import logging +import re + +from aiohttp import ClientSession from switchbee.api import CentralUnitPolling, CentralUnitWsRPC, is_wsrpc_api from switchbee.api.central_unit import SwitchBeeError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.device_registry as dr +import homeassistant.helpers.entity_registry as er from .const import DOMAIN from .coordinator import SwitchBeeCoordinator +_LOGGER = logging.getLogger(__name__) + + PLATFORMS: list[Platform] = [ Platform.BUTTON, Platform.CLIMATE, @@ -23,18 +32,14 @@ PLATFORMS: list[Platform] = [ ] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up SwitchBee Smart Home from a config entry.""" +async def get_api_object( + central_unit: str, user: str, password: str, websession: ClientSession +) -> CentralUnitPolling | CentralUnitWsRPC: + """Return SwitchBee API object.""" - hass.data.setdefault(DOMAIN, {}) - central_unit = entry.data[CONF_HOST] - user = entry.data[CONF_USERNAME] - password = entry.data[CONF_PASSWORD] - websession = async_get_clientsession(hass, verify_ssl=False) api: CentralUnitPolling | CentralUnitWsRPC = CentralUnitPolling( central_unit, user, password, websession ) - # First try to connect and fetch the version try: await api.connect() @@ -46,6 +51,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: api = CentralUnitWsRPC(central_unit, user, password, websession) await api.connect() + return api + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up SwitchBee Smart Home from a config entry.""" + + hass.data.setdefault(DOMAIN, {}) + central_unit = entry.data[CONF_HOST] + user = entry.data[CONF_USERNAME] + password = entry.data[CONF_PASSWORD] + websession = async_get_clientsession(hass, verify_ssl=False) + api = await get_api_object(central_unit, user, password, websession) + coordinator = SwitchBeeCoordinator( hass, api, @@ -71,3 +89,75 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Update listener.""" await hass.config_entries.async_reload(config_entry.entry_id) + + +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + _LOGGER.debug("Migrating from version %s", config_entry.version) + + if config_entry.version == 1: + dev_reg = dr.async_get(hass) + websession = async_get_clientsession(hass, verify_ssl=False) + old_unique_id = config_entry.unique_id + assert isinstance(old_unique_id, str) + api = await get_api_object( + config_entry.data[CONF_HOST], + config_entry.data[CONF_USERNAME], + config_entry.data[CONF_PASSWORD], + websession, + ) + new_unique_id = api.unique_id + + @callback + def update_unique_id(entity_entry: er.RegistryEntry) -> dict[str, str] | None: + """Update unique ID of entity entry.""" + if match := re.match( + rf"(?:{old_unique_id})-(?P\d+)", entity_entry.unique_id + ): + entity_new_unique_id = f'{new_unique_id}-{match.group("id")}' + _LOGGER.info( + "Migrating entity %s from %s to new id %s", + entity_entry.entity_id, + entity_entry.unique_id, + entity_new_unique_id, + ) + return {"new_unique_id": entity_new_unique_id} + + return None + + if new_unique_id: + # Migrate devices + for device_entry in dr.async_entries_for_config_entry( + dev_reg, config_entry.entry_id + ): + assert isinstance(device_entry, dr.DeviceEntry) + for identifier in device_entry.identifiers: + if match := re.match( + rf"(?P.+)-{old_unique_id}$", identifier[1] + ): + new_identifiers = { + ( + DOMAIN, + f"{match.group('id')}-{new_unique_id}", + ) + } + _LOGGER.info( + "Migrating device %s identifiers from %s to %s", + device_entry.name, + device_entry.identifiers, + new_identifiers, + ) + dev_reg.async_update_device( + device_entry.id, new_identifiers=new_identifiers + ) + + # Migrate entities + await er.async_migrate_entries( + hass, config_entry.entry_id, update_unique_id + ) + + config_entry.version = 2 + + _LOGGER.info("Migration to version %s successful", config_entry.version) + + return True diff --git a/homeassistant/components/switchbee/climate.py b/homeassistant/components/switchbee/climate.py index bf780e76eb0c..8dd740262f9a 100644 --- a/homeassistant/components/switchbee/climate.py +++ b/homeassistant/components/switchbee/climate.py @@ -103,7 +103,7 @@ class SwitchBeeClimateEntity(SwitchBeeDeviceEntity[SwitchBeeThermostat], Climate # set HVAC capabilities self._attr_max_temp = device.max_temperature self._attr_min_temp = device.min_temperature - self._attr_temperature_unit = HVAC_UNIT_SB_TO_HASS[device.unit] + self._attr_temperature_unit = HVAC_UNIT_SB_TO_HASS[device.temperature_unit] self._attr_hvac_modes = [HVAC_MODE_SB_TO_HASS[mode] for mode in device.modes] self._attr_hvac_modes.append(HVACMode.OFF) self._update_attrs_from_coordinator() diff --git a/homeassistant/components/switchbee/config_flow.py b/homeassistant/components/switchbee/config_flow.py index cb9cc27c1d05..8f109c7bf267 100644 --- a/homeassistant/components/switchbee/config_flow.py +++ b/homeassistant/components/switchbee/config_flow.py @@ -46,6 +46,9 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> str: raise CannotConnect from exp + if api.unique_id: + return api.unique_id + assert api.mac is not None return format_mac(api.mac) @@ -53,7 +56,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> str: class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for SwitchBee Smart Home.""" - VERSION = 1 + VERSION = 2 async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -67,7 +70,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) try: - mac_formatted = await validate_input(self.hass, user_input) + unique_id = await validate_input(self.hass, user_input) except CannotConnect: errors["base"] = "cannot_connect" except InvalidAuth: @@ -77,7 +80,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "unknown" else: - await self.async_set_unique_id(mac_formatted) + await self.async_set_unique_id(unique_id) self._abort_if_unique_id_configured() return self.async_create_entry(title=user_input[CONF_HOST], data=user_input) diff --git a/homeassistant/components/switchbee/coordinator.py b/homeassistant/components/switchbee/coordinator.py index b1b606615dd5..49400e3c28d6 100644 --- a/homeassistant/components/switchbee/coordinator.py +++ b/homeassistant/components/switchbee/coordinator.py @@ -30,10 +30,12 @@ class SwitchBeeCoordinator(DataUpdateCoordinator[Mapping[int, SwitchBeeBaseDevic """Initialize.""" self.api: CentralUnitPolling | CentralUnitWsRPC = swb_api self._reconnect_counts: int = 0 - self.mac_formatted: str | None = ( - None if self.api.mac is None else format_mac(self.api.mac) + assert self.api.mac is not None + self.unique_id = ( + self.api.unique_id + if self.api.unique_id is not None + else format_mac(self.api.mac) ) - super().__init__( hass, _LOGGER, @@ -77,6 +79,7 @@ class SwitchBeeCoordinator(DataUpdateCoordinator[Mapping[int, SwitchBeeBaseDevic DeviceType.Shutter, DeviceType.Somfy, DeviceType.Thermostat, + DeviceType.VRFAC, ] ) except SwitchBeeError as exp: diff --git a/homeassistant/components/switchbee/entity.py b/homeassistant/components/switchbee/entity.py index 7e5df69fb6db..4f6a056202cb 100644 --- a/homeassistant/components/switchbee/entity.py +++ b/homeassistant/components/switchbee/entity.py @@ -3,7 +3,6 @@ import logging from typing import Generic, TypeVar, cast from switchbee import SWITCHBEE_BRAND -from switchbee.api.central_unit import SwitchBeeDeviceOfflineError, SwitchBeeError from switchbee.device import DeviceType, SwitchBeeBaseDevice from homeassistant.helpers.entity import DeviceInfo @@ -32,7 +31,7 @@ class SwitchBeeEntity(CoordinatorEntity[SwitchBeeCoordinator], Generic[_DeviceTy super().__init__(coordinator) self._device = device self._attr_name = device.name - self._attr_unique_id = f"{coordinator.mac_formatted}-{device.id}" + self._attr_unique_id = f"{coordinator.unique_id}-{device.id}" class SwitchBeeDeviceEntity(SwitchBeeEntity[_DeviceTypeT]): @@ -54,7 +53,7 @@ class SwitchBeeDeviceEntity(SwitchBeeEntity[_DeviceTypeT]): identifiers={ ( DOMAIN, - f"{identifier}-{coordinator.mac_formatted}", + f"{identifier}-{coordinator.unique_id}", ) }, manufacturer=SWITCHBEE_BRAND, @@ -62,7 +61,7 @@ class SwitchBeeDeviceEntity(SwitchBeeEntity[_DeviceTypeT]): suggested_area=device.zone, via_device=( DOMAIN, - f"{coordinator.api.name} ({coordinator.api.mac})", + f"{coordinator.api.name} ({coordinator.api.unique_id})", ), ) @@ -71,30 +70,8 @@ class SwitchBeeDeviceEntity(SwitchBeeEntity[_DeviceTypeT]): """Return True if entity is available.""" return self._is_online and super().available - async def async_refresh_state(self) -> None: - """Refresh the device state in the Central Unit. - - This function addresses issue of a device that came online back but still report - unavailable state (-1). - Such device (offline device) will keep reporting unavailable state (-1) - until it has been actuated by the user (state changed to on/off). - - With this code we keep trying setting dummy state for the device - in order for it to start reporting its real state back (assuming it came back online) - - """ - - try: - await self.coordinator.api.set_state(self._device.id, "dummy") - except SwitchBeeDeviceOfflineError: - return - except SwitchBeeError: - return - def _check_if_became_offline(self) -> None: """Check if the device was online (now offline), log message and mark it as Unavailable.""" - # This specific call will refresh the state of the device in the CU - self.hass.async_create_task(self.async_refresh_state()) if self._is_online: _LOGGER.warning( diff --git a/homeassistant/components/switchbee/manifest.json b/homeassistant/components/switchbee/manifest.json index 3862539cb89d..2175f28eedec 100644 --- a/homeassistant/components/switchbee/manifest.json +++ b/homeassistant/components/switchbee/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/switchbee", "iot_class": "local_push", - "requirements": ["pyswitchbee==1.7.19"] + "requirements": ["pyswitchbee==1.8.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 80d0e22ca063..8b86c8d3779a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2014,7 +2014,7 @@ pystiebeleltron==0.0.1.dev2 pysuez==0.1.19 # homeassistant.components.switchbee -pyswitchbee==1.7.19 +pyswitchbee==1.8.0 # homeassistant.components.syncthru pysyncthru==0.7.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7118f312496a..85e09fdf7734 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1488,7 +1488,7 @@ pyspcwebgw==0.4.0 pysqueezebox==0.6.1 # homeassistant.components.switchbee -pyswitchbee==1.7.19 +pyswitchbee==1.8.0 # homeassistant.components.syncthru pysyncthru==0.7.10 diff --git a/tests/components/switchbee/test_config_flow.py b/tests/components/switchbee/test_config_flow.py index baa342807556..239777a4da35 100644 --- a/tests/components/switchbee/test_config_flow.py +++ b/tests/components/switchbee/test_config_flow.py @@ -2,6 +2,8 @@ import json from unittest.mock import patch +import pytest + from homeassistant import config_entries from homeassistant.components.switchbee.config_flow import SwitchBeeError from homeassistant.components.switchbee.const import DOMAIN @@ -14,10 +16,15 @@ from . import MOCK_FAILED_TO_LOGIN_MSG, MOCK_INVALID_TOKEN_MGS from tests.common import MockConfigEntry, load_fixture -async def test_form(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("test_cucode_in_coordinator_data", [False, True]) +async def test_form(hass: HomeAssistant, test_cucode_in_coordinator_data) -> None: """Test we get the form.""" coordinator_data = json.loads(load_fixture("switchbee.json", "switchbee")) + + if test_cucode_in_coordinator_data: + coordinator_data["data"]["cuCode"] = "300F123456" + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) From 9b53484e2ea28fcd78a90c784bd55531776ceec7 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Wed, 24 May 2023 04:02:50 -0400 Subject: [PATCH 500/822] Remove unused zwave discovery logic (#93436) --- .../components/zwave_js/discovery.py | 75 +++++-------------- 1 file changed, 20 insertions(+), 55 deletions(-) diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 79a37c8d9fab..3d7139945fa8 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -143,8 +143,6 @@ class ZWaveValueDiscoverySchema(DataclassMustHaveAtLeastOne): property_name: set[str] | None = None # [optional] the value's property key must match ANY of these values property_key: set[str | int | None] | None = None - # [optional] the value's property key name must match ANY of these values - property_key_name: set[str | None] | None = None # [optional] the value's metadata_type must match ANY of these values type: set[str] | None = None # [optional] the value's states map must include ANY of these key/value pairs @@ -176,14 +174,10 @@ class ZWaveDiscoverySchema: product_type: set[int] | None = None # [optional] the node's firmware_version must be within this range firmware_version_range: FirmwareVersionRange | None = None - # [optional] the node's firmware_version must match ANY of these values - firmware_version: set[str] | None = None - # [optional] the node's basic device class must match ANY of these values - device_class_basic: set[str | int] | None = None # [optional] the node's generic device class must match ANY of these values - device_class_generic: set[str | int] | None = None + device_class_generic: set[str] | None = None # [optional] the node's specific device class must match ANY of these values - device_class_specific: set[str | int] | None = None + device_class_specific: set[str] | None = None # [optional] additional values that ALL need to be present # on the node for this scheme to pass required_values: list[ZWaveValueDiscoverySchema] | None = None @@ -204,7 +198,6 @@ def get_config_parameter_discovery_schema( property_: set[str | int] | None = None, property_name: set[str] | None = None, property_key: set[str | int | None] | None = None, - property_key_name: set[str | None] | None = None, **kwargs: Any, ) -> ZWaveDiscoverySchema: """Return a discovery schema for a config parameter. @@ -220,7 +213,6 @@ def get_config_parameter_discovery_schema( property=property_, property_name=property_name, property_key=property_key, - property_key_name=property_key_name, type={ValueType.NUMBER}, ), entity_registry_enabled_default=False, @@ -928,9 +920,8 @@ def async_discover_node_values( """Run discovery on ZWave node and return matching (primary) values.""" for value in node.values.values(): # We don't need to rediscover an already processed value_id - if value.value_id in discovered_value_ids[device.id]: - continue - yield from async_discover_single_value(value, device, discovered_value_ids) + if value.value_id not in discovered_value_ids[device.id]: + yield from async_discover_single_value(value, device, discovered_value_ids) @callback @@ -940,24 +931,20 @@ def async_discover_single_value( """Run discovery on a single ZWave value and return matching schema info.""" discovered_value_ids[device.id].add(value.value_id) for schema in DISCOVERY_SCHEMAS: - # check manufacturer_id + # check manufacturer_id, product_id, product_type if ( - schema.manufacturer_id is not None - and value.node.manufacturer_id not in schema.manufacturer_id - ): - continue - - # check product_id - if ( - schema.product_id is not None - and value.node.product_id not in schema.product_id - ): - continue - - # check product_type - if ( - schema.product_type is not None - and value.node.product_type not in schema.product_type + ( + schema.manufacturer_id is not None + and value.node.manufacturer_id not in schema.manufacturer_id + ) + or ( + schema.product_id is not None + and value.node.product_id not in schema.product_id + ) + or ( + schema.product_type is not None + and value.node.product_type not in schema.product_type + ) ): continue @@ -976,19 +963,6 @@ def async_discover_single_value( ): continue - # check firmware_version - if ( - schema.firmware_version is not None - and value.node.firmware_version not in schema.firmware_version - ): - continue - - # check device_class_basic - if value.node.device_class and not check_device_class( - value.node.device_class.basic, schema.device_class_basic - ): - continue - # check device_class_generic if value.node.device_class and not check_device_class( value.node.device_class.generic, schema.device_class_generic @@ -1084,12 +1058,6 @@ def check_value(value: ZwaveValue, schema: ZWaveValueDiscoverySchema) -> bool: and value.property_key not in schema.property_key ): return False - # check property_key_name - if ( - schema.property_key_name is not None - and value.property_key_name not in schema.property_key_name - ): - return False # check metadata_type if schema.type is not None and value.metadata.type not in schema.type: return False @@ -1108,14 +1076,11 @@ def check_value(value: ZwaveValue, schema: ZWaveValueDiscoverySchema) -> bool: @callback def check_device_class( - device_class: DeviceClassItem, required_value: set[str | int] | None + device_class: DeviceClassItem, required_value: set[str] | None ) -> bool: """Check if device class id or label matches.""" if required_value is None: return True - for val in required_value: - if isinstance(val, str) and device_class.label == val: - return True - if isinstance(val, int) and device_class.key == val: - return True + if any(device_class.label == val for val in required_value): + return True return False From 08a719e09e924c169029998ea5a4b1476567d702 Mon Sep 17 00:00:00 2001 From: Mike Degatano Date: Wed, 24 May 2023 04:33:41 -0400 Subject: [PATCH 501/822] Add backup location and mount failed repair (#93126) * Add backup location and mount failed repair * Fix coverage * Change storage_location to backup_location * Use backticks * Update homeassistant/helpers/selector.py --------- Co-authored-by: Erik Montnemery --- homeassistant/components/hassio/__init__.py | 4 + homeassistant/components/hassio/const.py | 1 + homeassistant/components/hassio/issues.py | 1 + homeassistant/components/hassio/repairs.py | 18 +++- homeassistant/components/hassio/services.yaml | 12 +++ homeassistant/components/hassio/strings.json | 17 ++++ homeassistant/helpers/selector.py | 22 +++++ tests/components/hassio/test_init.py | 28 ++++++ tests/components/hassio/test_repairs.py | 99 +++++++++++++++++++ tests/helpers/test_selector.py | 11 +++ 10 files changed, 208 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index c8f4b69d429d..4091fa1d984f 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -61,6 +61,7 @@ from .const import ( ATTR_FOLDERS, ATTR_HOMEASSISTANT, ATTR_INPUT, + ATTR_LOCATION, ATTR_PASSWORD, ATTR_REPOSITORY, ATTR_SLUG, @@ -161,6 +162,9 @@ SCHEMA_BACKUP_FULL = vol.Schema( vol.Optional(ATTR_NAME): cv.string, vol.Optional(ATTR_PASSWORD): cv.string, vol.Optional(ATTR_COMPRESSED): cv.boolean, + vol.Optional(ATTR_LOCATION): vol.All( + cv.string, lambda v: None if v == "/backup" else v + ), } ) diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index 1dfd5ce53cd6..edd7086fd80f 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -61,6 +61,7 @@ ATTR_VERSION = "version" ATTR_VERSION_LATEST = "version_latest" ATTR_CPU_PERCENT = "cpu_percent" ATTR_CHANGELOG = "changelog" +ATTR_LOCATION = "location" ATTR_MEMORY_PERCENT = "memory_percent" ATTR_SLUG = "slug" ATTR_STATE = "state" diff --git a/homeassistant/components/hassio/issues.py b/homeassistant/components/hassio/issues.py index ac6af7f3489e..314b01ec388f 100644 --- a/homeassistant/components/hassio/issues.py +++ b/homeassistant/components/hassio/issues.py @@ -85,6 +85,7 @@ UNHEALTHY_REASONS = { # Keys (type + context) of issues that when found should be made into a repair ISSUE_KEYS_FOR_REPAIRS = { + "issue_mount_mount_failed", "issue_system_multiple_data_disks", "issue_system_reboot_required", } diff --git a/homeassistant/components/hassio/repairs.py b/homeassistant/components/hassio/repairs.py index 50a9b087a7cd..5874c99f13d2 100644 --- a/homeassistant/components/hassio/repairs.py +++ b/homeassistant/components/hassio/repairs.py @@ -16,6 +16,12 @@ from .issues import Issue, Suggestion, SupervisorIssues SUGGESTION_CONFIRMATION_REQUIRED = {"system_execute_reboot"} +EXTRA_PLACEHOLDERS = { + "issue_mount_mount_failed": { + "storage_url": "/config/storage", + } +} + class SupervisorIssueRepairFlow(RepairsFlow): """Handler for an issue fixing flow.""" @@ -42,11 +48,13 @@ class SupervisorIssueRepairFlow(RepairsFlow): @property def description_placeholders(self) -> dict[str, str] | None: """Get description placeholders for steps.""" - return ( - {PLACEHOLDER_KEY_REFERENCE: self.issue.reference} - if self.issue and self.issue.reference - else None - ) + placeholders = {} + if self.issue: + placeholders = EXTRA_PLACEHOLDERS.get(self.issue.key, {}) + if self.issue.reference: + placeholders |= {PLACEHOLDER_KEY_REFERENCE: self.issue.reference} + + return placeholders or None def _async_form_for_suggestion(self, suggestion: Suggestion) -> FlowResult: """Return form for suggestion.""" diff --git a/homeassistant/components/hassio/services.yaml b/homeassistant/components/hassio/services.yaml index e526074b1a9b..60b547354932 100644 --- a/homeassistant/components/hassio/services.yaml +++ b/homeassistant/components/hassio/services.yaml @@ -88,6 +88,12 @@ backup_full: default: true selector: boolean: + location: + name: Location + description: Name of a backup network storage to put backup (or /backup) + example: my_backup_mount + selector: + backup_location: backup_partial: name: Create a partial backup. @@ -128,6 +134,12 @@ backup_partial: default: true selector: boolean: + location: + name: Location + description: Name of a backup network storage to put backup (or /backup) + example: my_backup_mount + selector: + backup_location: restore_full: name: Restore from full backup. diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json index 36cd61a0bb4d..5ec63be7032b 100644 --- a/homeassistant/components/hassio/strings.json +++ b/homeassistant/components/hassio/strings.json @@ -17,6 +17,23 @@ } }, "issues": { + "issue_mount_mount_failed": { + "title": "Network storage device failed", + "fix_flow": { + "step": { + "fix_menu": { + "description": "Could not connect to `{reference}`. Check host logs for errors from the mount service for more details.\n\nUse reload to try to connect again. If you need to update `{reference}`, go to [storage]({storage_url}).", + "menu_options": { + "mount_execute_reload": "Reload", + "mount_execute_remove": "Remove" + } + } + }, + "abort": { + "apply_suggestion_fail": "Could not apply the fix. Check the supervisor logs for more details." + } + } + }, "issue_system_multiple_data_disks": { "title": "Multiple data disks detected", "fix_flow": { diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index fec9d25563e7..2e7df07cf048 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -340,6 +340,28 @@ class AttributeSelector(Selector[AttributeSelectorConfig]): return attribute +class BackupLocationSelectorConfig(TypedDict, total=False): + """Class to represent a backup location selector config.""" + + +@SELECTORS.register("backup_location") +class BackupLocationSelector(Selector[BackupLocationSelectorConfig]): + """Selector of a backup location.""" + + selector_type = "backup_location" + + CONFIG_SCHEMA = vol.Schema({}) + + def __init__(self, config: BackupLocationSelectorConfig | None = None) -> None: + """Instantiate a selector.""" + super().__init__(config) + + def __call__(self, data: Any) -> str: + """Validate the passed selection.""" + name: str = vol.Match(r"^(?:\/backup|\w+)$")(data) + return name + + class BooleanSelectorConfig(TypedDict): """Class to represent a boolean selector config.""" diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 1d86699d0952..9d83537859a4 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -571,6 +571,34 @@ async def test_service_calls( "password": "123456", } + await hass.services.async_call( + "hassio", + "backup_full", + { + "location": "backup_share", + }, + ) + await hass.async_block_till_done() + + assert aioclient_mock.call_count == 17 + assert aioclient_mock.mock_calls[-1][2] == { + "location": "backup_share", + } + + await hass.services.async_call( + "hassio", + "backup_full", + { + "location": "/backup", + }, + ) + await hass.async_block_till_done() + + assert aioclient_mock.call_count == 18 + assert aioclient_mock.mock_calls[-1][2] == { + "location": None, + } + async def test_service_calls_core( hassio_env, hass: HomeAssistant, aioclient_mock: AiohttpClientMocker diff --git a/tests/components/hassio/test_repairs.py b/tests/components/hassio/test_repairs.py index 76b6b48b460b..acef1cdd715c 100644 --- a/tests/components/hassio/test_repairs.py +++ b/tests/components/hassio/test_repairs.py @@ -400,3 +400,102 @@ async def test_supervisor_issue_repair_flow_skip_confirmation( str(aioclient_mock.mock_calls[-1][1]) == "http://127.0.0.1/resolution/suggestion/1235" ) + + +async def test_mount_failed_repair_flow( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_client: ClientSessionGenerator, +) -> None: + """Test repair flow for mount_failed issue.""" + issue_registry: ir.IssueRegistry = ir.async_get(hass) + mock_resolution_info( + aioclient_mock, + issues=[ + { + "uuid": "1234", + "type": "mount_failed", + "context": "mount", + "reference": "backup_share", + "suggestions": [ + { + "uuid": "1235", + "type": "execute_reload", + "context": "mount", + "reference": "backup_share", + }, + { + "uuid": "1236", + "type": "execute_remove", + "context": "mount", + "reference": "backup_share", + }, + ], + }, + ], + ) + + result = await async_setup_component(hass, "hassio", {}) + assert result + + repair_issue = issue_registry.async_get_issue(domain="hassio", issue_id="1234") + assert repair_issue + + client = await hass_client() + + resp = await client.post( + "/api/repairs/issues/fix", + json={"handler": "hassio", "issue_id": repair_issue.issue_id}, + ) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data == { + "type": "menu", + "flow_id": flow_id, + "handler": "hassio", + "step_id": "fix_menu", + "data_schema": [ + { + "type": "select", + "options": [ + ["mount_execute_reload", "mount_execute_reload"], + ["mount_execute_remove", "mount_execute_remove"], + ], + "name": "next_step_id", + } + ], + "menu_options": ["mount_execute_reload", "mount_execute_remove"], + "description_placeholders": { + "reference": "backup_share", + "storage_url": "/config/storage", + }, + } + + resp = await client.post( + f"/api/repairs/issues/fix/{flow_id}", + json={"next_step_id": "mount_execute_reload"}, + ) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data == { + "version": 1, + "type": "create_entry", + "flow_id": flow_id, + "handler": "hassio", + "description": None, + "description_placeholders": None, + } + + assert not issue_registry.async_get_issue(domain="hassio", issue_id="1234") + + assert aioclient_mock.mock_calls[-1][0] == "post" + assert ( + str(aioclient_mock.mock_calls[-1][1]) + == "http://127.0.0.1/resolution/suggestion/1235" + ) diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index 3c04087e48d5..f95da5c6e66e 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -415,6 +415,17 @@ def test_addon_selector_schema(schema, valid_selections, invalid_selections) -> _test_selector("addon", schema, valid_selections, invalid_selections) +@pytest.mark.parametrize( + ("schema", "valid_selections", "invalid_selections"), + (({}, ("abc123", "/backup"), (None, "abc@123", "abc 123", "")),), +) +def test_backup_location_selector_schema( + schema, valid_selections, invalid_selections +) -> None: + """Test backup location selector.""" + _test_selector("backup_location", schema, valid_selections, invalid_selections) + + @pytest.mark.parametrize( ("schema", "valid_selections", "invalid_selections"), (({}, (1, "one", None), ()),), # Everything can be coerced to bool From 746a47f094d53bf4c060ec2930c89c3f357baeb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Wed, 24 May 2023 11:37:41 +0200 Subject: [PATCH 502/822] Update aioairzone-cloud to v0.1.3 (#93443) --- 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 cc0e05f59689..0099b30e74ba 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.1.2"] + "requirements": ["aioairzone-cloud==0.1.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8b86c8d3779a..71fca46bb3a9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -116,7 +116,7 @@ aio_georss_gdacs==0.8 aioairq==0.2.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.1.2 +aioairzone-cloud==0.1.3 # homeassistant.components.airzone aioairzone==0.5.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 85e09fdf7734..1e315192b803 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -106,7 +106,7 @@ aio_georss_gdacs==0.8 aioairq==0.2.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.1.2 +aioairzone-cloud==0.1.3 # homeassistant.components.airzone aioairzone==0.5.6 From a8ce4d3c668bf404c3f1a20ffecc0fcf0b0878fe Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 24 May 2023 11:42:49 +0200 Subject: [PATCH 503/822] Remove legacy translations from electrasmart (#93446) --- .../electrasmart/translations/en.json | 25 ------------------- 1 file changed, 25 deletions(-) delete mode 100644 homeassistant/components/electrasmart/translations/en.json diff --git a/homeassistant/components/electrasmart/translations/en.json b/homeassistant/components/electrasmart/translations/en.json deleted file mode 100644 index c6afd6245402..000000000000 --- a/homeassistant/components/electrasmart/translations/en.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Phone number already configured" - }, - "error": { - "cannot_connect": "Failed to connect to Electra API", - "invalid_auth": "Wrong one time password key", - "invalid_phone_number": "Either wrong phone number or unregistered user", - "unknown": "Unexpected error" - }, - "step": { - "user": { - "data": { - "phone_number": "Phone Number (eg. 0501234567)" - } - }, - "one_time_password": { - "data": { - "one_time_password": "One Time Password (OTP)" - } - } - } - } -} \ No newline at end of file From 937ebb374c34b363d099913f253895cda14e7725 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 24 May 2023 11:46:11 +0200 Subject: [PATCH 504/822] Enable strict type checks of cloud (#92576) * Enable strict type checks of cloud * Fix circular import * Address review comments * Apply suggestions from code review --- .strict-typing | 1 + homeassistant/components/alexa/config.py | 12 ++- .../components/alexa/state_report.py | 13 ++- homeassistant/components/cloud/__init__.py | 33 +++---- .../components/cloud/account_link.py | 22 +++-- .../components/cloud/alexa_config.py | 60 ++++++------ .../components/cloud/binary_sensor.py | 11 ++- homeassistant/components/cloud/client.py | 17 ++-- .../components/cloud/google_config.py | 58 ++++++----- homeassistant/components/cloud/http_api.py | 77 +++++++++++---- homeassistant/components/cloud/prefs.py | 97 +++++++++++-------- homeassistant/components/cloud/repairs.py | 3 +- homeassistant/components/cloud/stt.py | 13 ++- .../components/cloud/subscription.py | 7 +- .../components/cloud/system_health.py | 10 +- homeassistant/components/cloud/tts.py | 39 +++++--- .../components/google_assistant/helpers.py | 26 ++--- .../components/google_assistant/trait.py | 9 +- mypy.ini | 10 ++ tests/components/cloud/test_init.py | 2 +- 20 files changed, 324 insertions(+), 196 deletions(-) diff --git a/.strict-typing b/.strict-typing index 9ed4bf534970..958c53d07dbb 100644 --- a/.strict-typing +++ b/.strict-typing @@ -87,6 +87,7 @@ homeassistant.components.camera.* homeassistant.components.canary.* homeassistant.components.clickatell.* homeassistant.components.clicksend.* +homeassistant.components.cloud.* homeassistant.components.configurator.* homeassistant.components.cover.* homeassistant.components.cpuspeed.* diff --git a/homeassistant/components/alexa/config.py b/homeassistant/components/alexa/config.py index e086d525cf12..d47a548979e9 100644 --- a/homeassistant/components/alexa/config.py +++ b/homeassistant/components/alexa/config.py @@ -1,4 +1,6 @@ """Config helpers for Alexa.""" +from __future__ import annotations + from abc import ABC, abstractmethod import asyncio import logging @@ -17,15 +19,15 @@ _LOGGER = logging.getLogger(__name__) class AbstractConfig(ABC): """Hold the configuration for Alexa.""" + _store: AlexaConfigStore _unsub_proactive_report: CALLBACK_TYPE | None = None def __init__(self, hass: HomeAssistant) -> None: """Initialize abstract config.""" self.hass = hass self._enable_proactive_mode_lock = asyncio.Lock() - self._store = None - async def async_initialize(self): + async def async_initialize(self) -> None: """Perform async initialization of config.""" self._store = AlexaConfigStore(self.hass) await self._store.async_load() @@ -65,7 +67,7 @@ class AbstractConfig(ABC): def user_identifier(self): """Return an identifier for the user that represents this config.""" - async def async_enable_proactive_mode(self): + async def async_enable_proactive_mode(self) -> None: """Enable proactive mode.""" _LOGGER.debug("Enable proactive mode") async with self._enable_proactive_mode_lock: @@ -75,7 +77,7 @@ class AbstractConfig(ABC): self.hass, self ) - async def async_disable_proactive_mode(self): + async def async_disable_proactive_mode(self) -> None: """Disable proactive mode.""" _LOGGER.debug("Disable proactive mode") if unsub_func := self._unsub_proactive_report: @@ -105,7 +107,7 @@ class AbstractConfig(ABC): """Return authorization status.""" return self._store.authorized - async def set_authorized(self, authorized): + async def set_authorized(self, authorized) -> None: """Set authorization status. - Set when an incoming message is received from Alexa. diff --git a/homeassistant/components/alexa/state_report.py b/homeassistant/components/alexa/state_report.py index a189c364c02d..ebab3bcee8ce 100644 --- a/homeassistant/components/alexa/state_report.py +++ b/homeassistant/components/alexa/state_report.py @@ -5,7 +5,7 @@ import asyncio from http import HTTPStatus import json import logging -from typing import cast +from typing import TYPE_CHECKING, cast import aiohttp import async_timeout @@ -23,6 +23,9 @@ from .entities import ENTITY_ADAPTERS, AlexaEntity, generate_alexa_id from .errors import NoTokenAvailable, RequireRelink from .messages import AlexaResponse +if TYPE_CHECKING: + from .config import AbstractConfig + _LOGGER = logging.getLogger(__name__) DEFAULT_TIMEOUT = 10 @@ -188,7 +191,9 @@ async def async_send_changereport_message( ) -async def async_send_add_or_update_message(hass, config, entity_ids): +async def async_send_add_or_update_message( + hass: HomeAssistant, config: AbstractConfig, entity_ids: list[str] +) -> aiohttp.ClientResponse: """Send an AddOrUpdateReport message for entities. https://developer.amazon.com/docs/device-apis/alexa-discovery.html#add-or-update-report @@ -223,7 +228,9 @@ async def async_send_add_or_update_message(hass, config, entity_ids): ) -async def async_send_delete_message(hass, config, entity_ids): +async def async_send_delete_message( + hass: HomeAssistant, config: AbstractConfig, entity_ids: list[str] +) -> aiohttp.ClientResponse: """Send an DeleteReport message for entities. https://developer.amazon.com/docs/device-apis/alexa-discovery.html#deletereport-event diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 0af85fe9d4d0..620e650315a6 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio from collections.abc import Awaitable, Callable -from datetime import timedelta +from datetime import datetime, timedelta from enum import Enum from hass_nabucasa import Cloud @@ -18,7 +18,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, Platform, ) -from homeassistant.core import HassJob, HomeAssistant, ServiceCall, callback +from homeassistant.core import Event, HassJob, HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entityfilter from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -31,7 +31,6 @@ from homeassistant.helpers.event import async_call_later from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass -from homeassistant.util.aiohttp import MockRequest from . import account_link, http_api from .client import CloudClient @@ -184,8 +183,10 @@ async def async_create_cloudhook(hass: HomeAssistant, webhook_id: str) -> str: if not async_is_logged_in(hass): raise CloudNotAvailable - hook = await hass.data[DOMAIN].cloudhooks.async_create(webhook_id, True) - return hook["cloudhook_url"] + cloud: Cloud[CloudClient] = hass.data[DOMAIN] + hook = await cloud.cloudhooks.async_create(webhook_id, True) + cloudhook_url: str = hook["cloudhook_url"] + return cloudhook_url @bind_hass @@ -213,14 +214,6 @@ def async_remote_ui_url(hass: HomeAssistant) -> str: return f"https://{remote_domain}" -def is_cloudhook_request(request): - """Test if a request came from a cloudhook. - - Async friendly. - """ - return isinstance(request, MockRequest) - - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Initialize the Home Assistant cloud.""" # Process configs @@ -243,7 +236,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: cloud = hass.data[DOMAIN] = Cloud(client, **kwargs) cloud.iot.register_on_connect(client.on_cloud_connected) - async def _shutdown(event): + async def _shutdown(event: Event) -> None: """Shutdown event.""" await cloud.stop() @@ -263,7 +256,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass, DOMAIN, SERVICE_REMOTE_DISCONNECT, _service_handler ) - async def async_startup_repairs(_=None) -> None: + async def async_startup_repairs(_: datetime) -> None: """Create repair issues after startup.""" if not cloud.is_logged_in: return @@ -273,7 +266,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: loaded = False - async def _on_start(): + async def _on_start() -> None: """Discover platforms.""" nonlocal loaded @@ -292,19 +285,19 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await async_load_platform(hass, Platform.TTS, DOMAIN, tts_info, config) await asyncio.gather(stt_platform_loaded.wait(), tts_platform_loaded.wait()) - async def _on_connect(): + async def _on_connect() -> None: """Handle cloud connect.""" async_dispatcher_send( hass, SIGNAL_CLOUD_CONNECTION_STATE, CloudConnectionState.CLOUD_CONNECTED ) - async def _on_disconnect(): + async def _on_disconnect() -> None: """Handle cloud disconnect.""" async_dispatcher_send( hass, SIGNAL_CLOUD_CONNECTION_STATE, CloudConnectionState.CLOUD_DISCONNECTED ) - async def _on_initialized(): + async def _on_initialized() -> None: """Update preferences.""" await prefs.async_update(remote_domain=cloud.remote.instance_domain) @@ -330,7 +323,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @callback -def _remote_handle_prefs_updated(cloud: Cloud) -> None: +def _remote_handle_prefs_updated(cloud: Cloud[CloudClient]) -> None: """Handle remote preferences updated.""" cur_pref = cloud.client.prefs.remote_enabled lock = asyncio.Lock() diff --git a/homeassistant/components/cloud/account_link.py b/homeassistant/components/cloud/account_link.py index e3b3c1231bba..1423330cb44f 100644 --- a/homeassistant/components/cloud/account_link.py +++ b/homeassistant/components/cloud/account_link.py @@ -1,5 +1,8 @@ """Account linking via the cloud.""" +from __future__ import annotations + import asyncio +from datetime import datetime import logging from typing import Any @@ -24,14 +27,16 @@ CURRENT_PLAIN_VERSION = AwesomeVersion( @callback -def async_setup(hass: HomeAssistant): +def async_setup(hass: HomeAssistant) -> None: """Set up cloud account link.""" config_entry_oauth2_flow.async_add_implementation_provider( hass, DOMAIN, async_provide_implementation ) -async def async_provide_implementation(hass: HomeAssistant, domain: str): +async def async_provide_implementation( + hass: HomeAssistant, domain: str +) -> list[config_entry_oauth2_flow.AbstractOAuth2Implementation]: """Provide an implementation for a domain.""" services = await _get_services(hass) @@ -55,9 +60,11 @@ async def async_provide_implementation(hass: HomeAssistant, domain: str): return [] -async def _get_services(hass): +async def _get_services(hass: HomeAssistant) -> list[dict[str, Any]]: """Get the available services.""" - if (services := hass.data.get(DATA_SERVICES)) is not None: + services: list[dict[str, Any]] + if DATA_SERVICES in hass.data: + services = hass.data[DATA_SERVICES] return services try: @@ -68,7 +75,7 @@ async def _get_services(hass): hass.data[DATA_SERVICES] = services @callback - def clear_services(_now): + def clear_services(_now: datetime) -> None: """Clear services cache.""" hass.data.pop(DATA_SERVICES, None) @@ -102,7 +109,7 @@ class CloudOAuth2Implementation(config_entry_oauth2_flow.AbstractOAuth2Implement ) authorize_url = await helper.async_get_authorize_url() - async def await_tokens(): + async def await_tokens() -> None: """Wait for tokens and pass them on when received.""" try: tokens = await helper.async_get_tokens() @@ -125,7 +132,8 @@ class CloudOAuth2Implementation(config_entry_oauth2_flow.AbstractOAuth2Implement async def async_resolve_external_data(self, external_data: Any) -> dict: """Resolve external data to tokens.""" # We already passed in tokens - return external_data + dict_data: dict = external_data + return dict_data async def _async_refresh_token(self, token: dict) -> dict: """Refresh a token.""" diff --git a/homeassistant/components/cloud/alexa_config.py b/homeassistant/components/cloud/alexa_config.py index 53bf44d8aa12..052fddabb54f 100644 --- a/homeassistant/components/cloud/alexa_config.py +++ b/homeassistant/components/cloud/alexa_config.py @@ -4,9 +4,10 @@ from __future__ import annotations import asyncio from collections.abc import Callable from contextlib import suppress -from datetime import timedelta +from datetime import datetime, timedelta from http import HTTPStatus import logging +from typing import TYPE_CHECKING, Any import aiohttp import async_timeout @@ -29,10 +30,11 @@ from homeassistant.components.homeassistant.exposed_entities import ( ) from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES -from homeassistant.core import HomeAssistant, callback, split_entity_id +from homeassistant.core import Event, HomeAssistant, callback, split_entity_id from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er, start from homeassistant.helpers.entity import get_device_class +from homeassistant.helpers.entityfilter import EntityFilter from homeassistant.helpers.event import async_call_later from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow @@ -47,6 +49,9 @@ from .const import ( ) from .prefs import ALEXA_SETTINGS_VERSION, CloudPreferences +if TYPE_CHECKING: + from .client import CloudClient + _LOGGER = logging.getLogger(__name__) CLOUD_ALEXA = f"{CLOUD_DOMAIN}.{ALEXA_DOMAIN}" @@ -132,7 +137,7 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): config: dict, cloud_user: str, prefs: CloudPreferences, - cloud: Cloud, + cloud: Cloud[CloudClient], ) -> None: """Initialize the Alexa config.""" super().__init__(hass) @@ -141,13 +146,13 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): self._prefs = prefs self._cloud = cloud self._token = None - self._token_valid = None + self._token_valid: datetime | None = None self._cur_entity_prefs = async_get_assistant_settings(hass, CLOUD_ALEXA) self._alexa_sync_unsub: Callable[[], None] | None = None - self._endpoint = None + self._endpoint: Any = None @property - def enabled(self): + def enabled(self) -> bool: """Return if Alexa is enabled.""" return ( self._cloud.is_logged_in @@ -156,12 +161,12 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): ) @property - def supports_auth(self): + def supports_auth(self) -> bool: """Return if config supports auth.""" return True @property - def should_report_state(self): + def should_report_state(self) -> bool: """Return if states should be proactively reported.""" return ( self._prefs.alexa_enabled @@ -170,7 +175,7 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): ) @property - def endpoint(self): + def endpoint(self) -> Any | None: """Endpoint for report state.""" if self._endpoint is None: raise ValueError("No endpoint available. Fetch access token first") @@ -178,22 +183,22 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): return self._endpoint @property - def locale(self): + def locale(self) -> str: """Return config locale.""" # Not clear how to determine locale atm. return "en-US" @property - def entity_config(self): + def entity_config(self) -> dict[str, Any]: """Return entity config.""" return self._config.get(CONF_ENTITY_CONFIG) or {} @callback - def user_identifier(self): + def user_identifier(self) -> str: """Return an identifier for the user that represents this config.""" return self._cloud_user - def _migrate_alexa_entity_settings_v1(self): + def _migrate_alexa_entity_settings_v1(self) -> None: """Migrate alexa entity settings to entity registry options.""" if not self._config[CONF_FILTER].empty_filter: # Don't migrate if there's a YAML config @@ -210,11 +215,11 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): self._should_expose_legacy(entity_id), ) - async def async_initialize(self): + async def async_initialize(self) -> None: """Initialize the Alexa config.""" await super().async_initialize() - async def on_hass_started(hass): + async def on_hass_started(hass: HomeAssistant) -> None: if self._prefs.alexa_settings_version != ALEXA_SETTINGS_VERSION: if self._prefs.alexa_settings_version < 2 or ( # Recover from a bug we had in 2023.5.0 where entities didn't get exposed @@ -235,7 +240,7 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): self.hass, CLOUD_ALEXA, self._async_exposed_entities_updated ) - async def on_hass_start(hass): + async def on_hass_start(hass: HomeAssistant) -> None: if self.enabled and ALEXA_DOMAIN not in self.hass.config.components: await async_setup_component(self.hass, ALEXA_DOMAIN, {}) @@ -248,14 +253,14 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): self._handle_entity_registry_updated, ) - def _should_expose_legacy(self, entity_id): + def _should_expose_legacy(self, entity_id: str) -> bool: """If an entity should be exposed.""" if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: return False entity_configs = self._prefs.alexa_entity_configs entity_config = entity_configs.get(entity_id, {}) - entity_expose = entity_config.get(PREF_SHOULD_EXPOSE) + entity_expose: bool | None = entity_config.get(PREF_SHOULD_EXPOSE) if entity_expose is not None: return entity_expose @@ -279,21 +284,22 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): ) @callback - def should_expose(self, entity_id): + def should_expose(self, entity_id: str) -> bool: """If an entity should be exposed.""" - if not self._config[CONF_FILTER].empty_filter: + entity_filter: EntityFilter = self._config[CONF_FILTER] + if not entity_filter.empty_filter: if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: return False - return self._config[CONF_FILTER](entity_id) + return entity_filter(entity_id) return async_should_expose(self.hass, CLOUD_ALEXA, entity_id) @callback - def async_invalidate_access_token(self): + def async_invalidate_access_token(self) -> None: """Invalidate access token.""" self._token_valid = None - async def async_get_access_token(self): + async def async_get_access_token(self) -> Any: """Get an access token.""" if self._token_valid is not None and self._token_valid > utcnow(): return self._token @@ -380,7 +386,7 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): self.hass, SYNC_DELAY, self._sync_prefs ) - async def _sync_prefs(self, _now): + async def _sync_prefs(self, _now: datetime) -> None: """Sync the updated preferences to Alexa.""" self._alexa_sync_unsub = None old_prefs = self._cur_entity_prefs @@ -432,7 +438,7 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): if await self._sync_helper(to_update, to_remove): self._cur_entity_prefs = new_prefs - async def async_sync_entities(self): + async def async_sync_entities(self) -> bool: """Sync all entities to Alexa.""" # Remove any pending sync if self._alexa_sync_unsub: @@ -452,7 +458,7 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): return await self._sync_helper(to_update, to_remove) - async def _sync_helper(self, to_update, to_remove) -> bool: + async def _sync_helper(self, to_update: list[str], to_remove: list[str]) -> bool: """Sync entities to Alexa. Return boolean if it was successful. @@ -497,7 +503,7 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): _LOGGER.warning("Error trying to sync entities to Alexa: %s", err) return False - async def _handle_entity_registry_updated(self, event): + async def _handle_entity_registry_updated(self, event: Event) -> None: """Handle when entity registry updated.""" if not self.enabled or not self._cloud.is_logged_in: return diff --git a/homeassistant/components/cloud/binary_sensor.py b/homeassistant/components/cloud/binary_sensor.py index 2d78ad8b512b..e09122ac7bf5 100644 --- a/homeassistant/components/cloud/binary_sensor.py +++ b/homeassistant/components/cloud/binary_sensor.py @@ -2,6 +2,10 @@ from __future__ import annotations import asyncio +from collections.abc import Callable +from typing import Any + +from hass_nabucasa import Cloud from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -13,6 +17,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from .client import CloudClient from .const import DISPATCHER_REMOTE_UPDATE, DOMAIN WAIT_UNTIL_CHANGE = 3 @@ -41,10 +46,10 @@ class CloudRemoteBinary(BinarySensorEntity): _attr_unique_id = "cloud-remote-ui-connectivity" _attr_entity_category = EntityCategory.DIAGNOSTIC - def __init__(self, cloud): + def __init__(self, cloud: Cloud[CloudClient]) -> None: """Initialize the binary sensor.""" self.cloud = cloud - self._unsub_dispatcher = None + self._unsub_dispatcher: Callable[[], None] | None = None @property def is_on(self) -> bool: @@ -59,7 +64,7 @@ class CloudRemoteBinary(BinarySensorEntity): async def async_added_to_hass(self) -> None: """Register update dispatcher.""" - async def async_state_update(data): + async def async_state_update(data: Any) -> None: """Update callback.""" await asyncio.sleep(WAIT_UNTIL_CHANGE) self.async_write_ha_state() diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index f3878fd68afb..dff3bdcdbddf 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +from datetime import datetime from http import HTTPStatus import logging from pathlib import Path @@ -95,7 +96,9 @@ class CloudClient(Interface): if self._alexa_config is None: async with self._alexa_config_init_lock: if self._alexa_config is not None: - return self._alexa_config + # This is reachable if the config was set while we waited + # for the lock + return self._alexa_config # type: ignore[unreachable] cloud_user = await self._prefs.get_cloud_user() @@ -136,7 +139,7 @@ class CloudClient(Interface): """When cloud is connected.""" is_new_user = await self.prefs.async_set_username(self.cloud.username) - async def enable_alexa(_): + async def enable_alexa(_: Any) -> None: """Enable Alexa.""" aconf = await self.get_alexa_config() try: @@ -156,7 +159,7 @@ class CloudClient(Interface): enable_alexa_job = HassJob(enable_alexa, cancel_on_shutdown=True) - async def enable_google(_): + async def enable_google(_: datetime) -> None: """Enable Google.""" gconf = await self.get_google_config() @@ -210,7 +213,7 @@ class CloudClient(Interface): """Process cloud alexa message to client.""" cloud_user = await self._prefs.get_cloud_user() aconfig = await self.get_alexa_config() - return await alexa_smart_home.async_handle_message( + return await alexa_smart_home.async_handle_message( # type: ignore[no-any-return, no-untyped-call] self._hass, aconfig, payload, @@ -223,9 +226,11 @@ class CloudClient(Interface): gconf = await self.get_google_config() if not self._prefs.google_enabled: - return ga.api_disabled_response(payload, gconf.agent_user_id) + return ga.api_disabled_response( # type: ignore[no-any-return, no-untyped-call] + payload, gconf.agent_user_id + ) - return await ga.async_handle_message( + return await ga.async_handle_message( # type: ignore[no-any-return, no-untyped-call] self._hass, gconf, gconf.cloud_user, payload, google_assistant.SOURCE_CLOUD ) diff --git a/homeassistant/components/cloud/google_config.py b/homeassistant/components/cloud/google_config.py index 351de5d0e654..8592a4ffbe35 100644 --- a/homeassistant/components/cloud/google_config.py +++ b/homeassistant/components/cloud/google_config.py @@ -1,8 +1,10 @@ """Google config for Cloud.""" +from __future__ import annotations + import asyncio from http import HTTPStatus import logging -from typing import Any +from typing import TYPE_CHECKING, Any from hass_nabucasa import Cloud, cloud_api from hass_nabucasa.google_report_state import ErrorResponse @@ -24,12 +26,14 @@ from homeassistant.core import ( CoreState, Event, HomeAssistant, + State, callback, split_entity_id, ) from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er, start from homeassistant.helpers.entity import get_device_class +from homeassistant.helpers.entityfilter import EntityFilter from homeassistant.setup import async_setup_component from .const import ( @@ -42,6 +46,9 @@ from .const import ( ) from .prefs import GOOGLE_SETTINGS_VERSION, CloudPreferences +if TYPE_CHECKING: + from .client import CloudClient + _LOGGER = logging.getLogger(__name__) CLOUD_GOOGLE = f"{CLOUD_DOMAIN}.{GOOGLE_DOMAIN}" @@ -123,7 +130,7 @@ class CloudGoogleConfig(AbstractConfig): config: dict[str, Any], cloud_user: str, prefs: CloudPreferences, - cloud: Cloud, + cloud: Cloud[CloudClient], ) -> None: """Initialize the Google config.""" super().__init__(hass) @@ -134,7 +141,7 @@ class CloudGoogleConfig(AbstractConfig): self._sync_entities_lock = asyncio.Lock() @property - def enabled(self): + def enabled(self) -> bool: """Return if Google is enabled.""" return ( self._cloud.is_logged_in @@ -143,34 +150,34 @@ class CloudGoogleConfig(AbstractConfig): ) @property - def entity_config(self): + def entity_config(self) -> dict[str, Any]: """Return entity config.""" return self._config.get(CONF_ENTITY_CONFIG) or {} @property - def secure_devices_pin(self): + def secure_devices_pin(self) -> str | None: """Return entity config.""" return self._prefs.google_secure_devices_pin @property - def should_report_state(self): + def should_report_state(self) -> bool: """Return if states should be proactively reported.""" return self.enabled and self._prefs.google_report_state - def get_local_webhook_id(self, agent_user_id): + def get_local_webhook_id(self, agent_user_id: Any) -> str: """Return the webhook ID to be used for actions for a given agent user id via the local SDK.""" return self._prefs.google_local_webhook_id - def get_local_agent_user_id(self, webhook_id): + def get_local_agent_user_id(self, webhook_id: Any) -> str: """Return the user ID to be used for actions received via the local SDK.""" return self._user @property - def cloud_user(self): + def cloud_user(self) -> str: """Return Cloud User account.""" return self._user - def _migrate_google_entity_settings_v1(self): + def _migrate_google_entity_settings_v1(self) -> None: """Migrate Google entity settings to entity registry options.""" if not self._config[CONF_FILTER].empty_filter: # Don't migrate if there's a YAML config @@ -195,7 +202,7 @@ class CloudGoogleConfig(AbstractConfig): _2fa_disabled, ) - async def async_initialize(self): + async def async_initialize(self) -> None: """Perform async initialization of config.""" await super().async_initialize() @@ -246,18 +253,18 @@ class CloudGoogleConfig(AbstractConfig): self._handle_device_registry_updated, ) - def should_expose(self, state): + def should_expose(self, state: State) -> bool: """If a state object should be exposed.""" return self._should_expose_entity_id(state.entity_id) - def _should_expose_legacy(self, entity_id): + def _should_expose_legacy(self, entity_id: str) -> bool: """If an entity ID should be exposed.""" if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: return False entity_configs = self._prefs.google_entity_configs entity_config = entity_configs.get(entity_id, {}) - entity_expose = entity_config.get(PREF_SHOULD_EXPOSE) + entity_expose: bool | None = entity_config.get(PREF_SHOULD_EXPOSE) if entity_expose is not None: return entity_expose @@ -282,36 +289,37 @@ class CloudGoogleConfig(AbstractConfig): and _supported_legacy(self.hass, entity_id) ) - def _should_expose_entity_id(self, entity_id): + def _should_expose_entity_id(self, entity_id: str) -> bool: """If an entity should be exposed.""" - if not self._config[CONF_FILTER].empty_filter: + entity_filter: EntityFilter = self._config[CONF_FILTER] + if not entity_filter.empty_filter: if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: return False - return self._config[CONF_FILTER](entity_id) + return entity_filter(entity_id) return async_should_expose(self.hass, CLOUD_GOOGLE, entity_id) @property - def agent_user_id(self): + def agent_user_id(self) -> str: """Return Agent User Id to use for query responses.""" return self._cloud.username @property - def has_registered_user_agent(self): + def has_registered_user_agent(self) -> bool: """Return if we have a Agent User Id registered.""" return len(self._store.agent_user_ids) > 0 - def get_agent_user_id(self, context): + def get_agent_user_id(self, context: Any) -> str: """Get agent user ID making request.""" return self.agent_user_id - def _2fa_disabled_legacy(self, entity_id): + def _2fa_disabled_legacy(self, entity_id: str) -> bool | None: """If an entity should be checked for 2FA.""" entity_configs = self._prefs.google_entity_configs entity_config = entity_configs.get(entity_id, {}) return entity_config.get(PREF_DISABLE_2FA) - def should_2fa(self, state): + def should_2fa(self, state: State) -> bool: """If an entity should be checked for 2FA.""" try: settings = async_get_entity_settings(self.hass, state.entity_id) @@ -322,14 +330,14 @@ 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, agent_user_id: str): + async def async_report_state(self, message: Any, agent_user_id: str) -> 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): + async def _async_request_sync_devices(self, agent_user_id: str) -> int: """Trigger a sync with Google.""" if self._sync_entities_lock.locked(): return HTTPStatus.OK @@ -338,7 +346,7 @@ class CloudGoogleConfig(AbstractConfig): resp = await cloud_api.async_google_actions_request_sync(self._cloud) return resp.status - async def _async_prefs_updated(self, prefs): + async def _async_prefs_updated(self, prefs: CloudPreferences) -> None: """Handle updated preferences.""" if not self._cloud.is_logged_in: if self.is_reporting_state: diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index f5d5c98fe1ac..84c348236d4b 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -1,14 +1,15 @@ """The HTTP api to control the cloud integration.""" import asyncio -from collections.abc import Mapping +from collections.abc import Awaitable, Callable, Coroutine, Mapping from contextlib import suppress import dataclasses from functools import wraps from http import HTTPStatus import logging -from typing import Any +from typing import Any, Concatenate, ParamSpec, TypeVar import aiohttp +from aiohttp import web import async_timeout import attr from hass_nabucasa import Cloud, auth, thingtalk @@ -32,6 +33,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util.location import async_detect_location_info from .alexa_config import entity_supported as entity_supported_by_alexa +from .client import CloudClient from .const import ( DOMAIN, PREF_ALEXA_REPORT_STATE, @@ -50,7 +52,7 @@ from .subscription import async_subscription_info _LOGGER = logging.getLogger(__name__) -_CLOUD_ERRORS = { +_CLOUD_ERRORS: dict[type[Exception], tuple[HTTPStatus, str]] = { asyncio.TimeoutError: ( HTTPStatus.BAD_GATEWAY, "Unable to reach the Home Assistant cloud.", @@ -62,7 +64,7 @@ _CLOUD_ERRORS = { } -async def async_setup(hass): +async def async_setup(hass: HomeAssistant) -> None: """Initialize the HTTP API.""" websocket_api.async_register_command(hass, websocket_cloud_status) websocket_api.async_register_command(hass, websocket_subscription) @@ -107,11 +109,21 @@ async def async_setup(hass): ) -def _handle_cloud_errors(handler): +_HassViewT = TypeVar("_HassViewT", bound=HomeAssistantView) +_P = ParamSpec("_P") + + +def _handle_cloud_errors( + handler: Callable[Concatenate[_HassViewT, web.Request, _P], Awaitable[web.Response]] +) -> Callable[ + Concatenate[_HassViewT, web.Request, _P], Coroutine[Any, Any, web.Response] +]: """Webview decorator to handle auth errors.""" @wraps(handler) - async def error_handler(view, request, *args, **kwargs): + async def error_handler( + view: _HassViewT, request: web.Request, *args: _P.args, **kwargs: _P.kwargs + ) -> web.Response: """Handle exceptions that raise from the wrapped request handler.""" try: result = await handler(view, request, *args, **kwargs) @@ -126,25 +138,37 @@ def _handle_cloud_errors(handler): return error_handler -def _ws_handle_cloud_errors(handler): +def _ws_handle_cloud_errors( + handler: Callable[ + [HomeAssistant, websocket_api.ActiveConnection, dict[str, Any]], + Coroutine[None, None, None], + ] +) -> Callable[ + [HomeAssistant, websocket_api.ActiveConnection, dict[str, Any]], + Coroutine[None, None, None], +]: """Websocket decorator to handle auth errors.""" @wraps(handler) - async def error_handler(hass, connection, msg): + async def error_handler( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], + ) -> None: """Handle exceptions that raise from the wrapped handler.""" try: return await handler(hass, connection, msg) except Exception as err: # pylint: disable=broad-except err_status, err_msg = _process_cloud_exception(err, msg["type"]) - connection.send_error(msg["id"], err_status, err_msg) + connection.send_error(msg["id"], str(err_status), err_msg) return error_handler -def _process_cloud_exception(exc, where): +def _process_cloud_exception(exc: Exception, where: str) -> tuple[HTTPStatus, str]: """Process a cloud exception.""" - err_info = None + err_info: tuple[HTTPStatus, str] | None = None for err, value_info in _CLOUD_ERRORS.items(): if isinstance(exc, err): @@ -165,10 +189,10 @@ class GoogleActionsSyncView(HomeAssistantView): name = "api:cloud:google_actions/sync" @_handle_cloud_errors - async def post(self, request): + async def post(self, request: web.Request) -> web.Response: """Trigger a Google Actions sync.""" hass = request.app["hass"] - cloud: Cloud = hass.data[DOMAIN] + cloud: Cloud[CloudClient] = hass.data[DOMAIN] gconf = await cloud.client.get_google_config() status = await gconf.async_sync_entities(gconf.agent_user_id) return self.json({}, status_code=status) @@ -184,7 +208,7 @@ class CloudLoginView(HomeAssistantView): @RequestDataValidator( vol.Schema({vol.Required("email"): str, vol.Required("password"): str}) ) - async def post(self, request, data): + async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response: """Handle login request.""" def cloud_assist_pipeline(hass: HomeAssistant) -> str | None: @@ -221,7 +245,7 @@ class CloudLogoutView(HomeAssistantView): name = "api:cloud:logout" @_handle_cloud_errors - async def post(self, request): + async def post(self, request: web.Request) -> web.Response: """Handle logout request.""" hass = request.app["hass"] cloud = hass.data[DOMAIN] @@ -247,7 +271,7 @@ class CloudRegisterView(HomeAssistantView): } ) ) - async def post(self, request, data): + async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response: """Handle registration request.""" hass = request.app["hass"] cloud = hass.data[DOMAIN] @@ -283,7 +307,7 @@ class CloudResendConfirmView(HomeAssistantView): @_handle_cloud_errors @RequestDataValidator(vol.Schema({vol.Required("email"): str})) - async def post(self, request, data): + async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response: """Handle resending confirm email code request.""" hass = request.app["hass"] cloud = hass.data[DOMAIN] @@ -302,7 +326,7 @@ class CloudForgotPasswordView(HomeAssistantView): @_handle_cloud_errors @RequestDataValidator(vol.Schema({vol.Required("email"): str})) - async def post(self, request, data): + async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response: """Handle forgot password request.""" hass = request.app["hass"] cloud = hass.data[DOMAIN] @@ -330,11 +354,20 @@ async def websocket_cloud_status( ) -def _require_cloud_login(handler): +def _require_cloud_login( + handler: Callable[ + [HomeAssistant, websocket_api.ActiveConnection, dict[str, Any]], + None, + ] +) -> Callable[[HomeAssistant, websocket_api.ActiveConnection, dict[str, Any]], None,]: """Websocket decorator that requires cloud to be logged in.""" @wraps(handler) - def with_cloud_auth(hass, connection, msg): + def with_cloud_auth( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], + ) -> None: """Require to be logged into the cloud.""" cloud = hass.data[DOMAIN] if not cloud.is_logged_in: @@ -467,7 +500,9 @@ async def websocket_hook_delete( connection.send_message(websocket_api.result_message(msg["id"])) -async def _account_data(hass: HomeAssistant, cloud: Cloud): +async def _account_data( + hass: HomeAssistant, cloud: Cloud[CloudClient] +) -> dict[str, Any]: """Generate the auth data JSON response.""" assert hass.config.api diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index 5ccc007e5241..46ddafd48e75 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -1,14 +1,15 @@ """Preference management for cloud.""" from __future__ import annotations +from collections.abc import Callable, Coroutine from typing import Any from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.auth.models import User from homeassistant.components import webhook -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.storage import Store -from homeassistant.helpers.typing import UNDEFINED +from homeassistant.helpers.typing import UNDEFINED, UndefinedType from homeassistant.util.logging import async_create_catching_coro from .const import ( @@ -63,17 +64,20 @@ class CloudPreferencesStore(Store): class CloudPreferences: """Handle cloud preferences.""" - def __init__(self, hass): + _prefs: dict[str, Any] + + def __init__(self, hass: HomeAssistant) -> None: """Initialize cloud prefs.""" self._hass = hass self._store = CloudPreferencesStore( hass, STORAGE_VERSION, STORAGE_KEY, minor_version=STORAGE_VERSION_MINOR ) - self._prefs = None - self._listeners = [] + self._listeners: list[ + Callable[[CloudPreferences], Coroutine[Any, Any, None]] + ] = [] self.last_updated: set[str] = set() - async def async_initialize(self): + async def async_initialize(self) -> None: """Finish initializing the preferences.""" if (prefs := await self._store.async_load()) is None: prefs = self._empty_config("") @@ -89,26 +93,28 @@ class CloudPreferences: ) @callback - def async_listen_updates(self, listener): + def async_listen_updates( + self, listener: Callable[[CloudPreferences], Coroutine[Any, Any, None]] + ) -> None: """Listen for updates to the preferences.""" self._listeners.append(listener) async def async_update( self, *, - google_enabled=UNDEFINED, - alexa_enabled=UNDEFINED, - remote_enabled=UNDEFINED, - google_secure_devices_pin=UNDEFINED, - cloudhooks=UNDEFINED, - cloud_user=UNDEFINED, - alexa_report_state=UNDEFINED, - google_report_state=UNDEFINED, - tts_default_voice=UNDEFINED, - remote_domain=UNDEFINED, - alexa_settings_version=UNDEFINED, - google_settings_version=UNDEFINED, - ): + google_enabled: bool | UndefinedType = UNDEFINED, + alexa_enabled: bool | UndefinedType = UNDEFINED, + remote_enabled: bool | UndefinedType = UNDEFINED, + google_secure_devices_pin: str | None | UndefinedType = UNDEFINED, + cloudhooks: dict[str, dict[str, str | bool]] | UndefinedType = UNDEFINED, + cloud_user: str | UndefinedType = UNDEFINED, + alexa_report_state: bool | UndefinedType = UNDEFINED, + google_report_state: bool | UndefinedType = UNDEFINED, + tts_default_voice: tuple[str, str] | UndefinedType = UNDEFINED, + remote_domain: str | None | UndefinedType = UNDEFINED, + alexa_settings_version: int | UndefinedType = UNDEFINED, + google_settings_version: int | UndefinedType = UNDEFINED, + ) -> None: """Update user preferences.""" prefs = {**self._prefs} @@ -131,7 +137,7 @@ class CloudPreferences: await self._save_prefs(prefs) - async def async_set_username(self, username) -> bool: + async def async_set_username(self, username: str | None) -> bool: """Set the username that is logged in.""" # Logging out. if username is None: @@ -154,7 +160,7 @@ class CloudPreferences: return True - def as_dict(self): + def as_dict(self) -> dict[str, Any]: """Return dictionary version.""" return { PREF_ALEXA_DEFAULT_EXPOSE: self.alexa_default_expose, @@ -170,7 +176,7 @@ class CloudPreferences: } @property - def remote_enabled(self): + def remote_enabled(self) -> bool: """Return if remote is enabled on start.""" if not self._prefs.get(PREF_ENABLE_REMOTE, False): return False @@ -178,17 +184,18 @@ class CloudPreferences: return True @property - def remote_domain(self): + def remote_domain(self) -> str | None: """Return remote domain.""" return self._prefs.get(PREF_REMOTE_DOMAIN) @property - def alexa_enabled(self): + def alexa_enabled(self) -> bool: """Return if Alexa is enabled.""" - return self._prefs[PREF_ENABLE_ALEXA] + alexa_enabled: bool = self._prefs[PREF_ENABLE_ALEXA] + return alexa_enabled @property - def alexa_report_state(self): + def alexa_report_state(self) -> bool: """Return if Alexa report state is enabled.""" return self._prefs.get(PREF_ALEXA_REPORT_STATE, DEFAULT_ALEXA_REPORT_STATE) @@ -201,44 +208,48 @@ class CloudPreferences: return self._prefs.get(PREF_ALEXA_DEFAULT_EXPOSE) @property - def alexa_entity_configs(self): + def alexa_entity_configs(self) -> dict[str, Any]: """Return Alexa Entity configurations.""" return self._prefs.get(PREF_ALEXA_ENTITY_CONFIGS, {}) @property - def alexa_settings_version(self): + def alexa_settings_version(self) -> int: """Return version of Alexa settings.""" - return self._prefs[PREF_ALEXA_SETTINGS_VERSION] + alexa_settings_version: int = self._prefs[PREF_ALEXA_SETTINGS_VERSION] + return alexa_settings_version @property - def google_enabled(self): + def google_enabled(self) -> bool: """Return if Google is enabled.""" - return self._prefs[PREF_ENABLE_GOOGLE] + google_enabled: bool = self._prefs[PREF_ENABLE_GOOGLE] + return google_enabled @property - def google_report_state(self): + def google_report_state(self) -> bool: """Return if Google report state is enabled.""" return self._prefs.get(PREF_GOOGLE_REPORT_STATE, DEFAULT_GOOGLE_REPORT_STATE) @property - def google_secure_devices_pin(self): + def google_secure_devices_pin(self) -> str | None: """Return if Google is allowed to unlock locks.""" return self._prefs.get(PREF_GOOGLE_SECURE_DEVICES_PIN) @property - def google_entity_configs(self): + def google_entity_configs(self) -> dict[str, dict[str, Any]]: """Return Google Entity configurations.""" return self._prefs.get(PREF_GOOGLE_ENTITY_CONFIGS, {}) @property - def google_settings_version(self): + def google_settings_version(self) -> int: """Return version of Google settings.""" - return self._prefs[PREF_GOOGLE_SETTINGS_VERSION] + google_settings_version: int = self._prefs[PREF_GOOGLE_SETTINGS_VERSION] + return google_settings_version @property - def google_local_webhook_id(self): + def google_local_webhook_id(self) -> str: """Return Google webhook ID to receive local messages.""" - return self._prefs[PREF_GOOGLE_LOCAL_WEBHOOK_ID] + google_local_webhook_id: str = self._prefs[PREF_GOOGLE_LOCAL_WEBHOOK_ID] + return google_local_webhook_id @property def google_default_expose(self) -> list[str] | None: @@ -249,12 +260,12 @@ class CloudPreferences: return self._prefs.get(PREF_GOOGLE_DEFAULT_EXPOSE) @property - def cloudhooks(self): + def cloudhooks(self) -> dict[str, Any]: """Return the published cloud webhooks.""" return self._prefs.get(PREF_CLOUDHOOKS, {}) @property - def tts_default_voice(self): + def tts_default_voice(self) -> tuple[str, str]: """Return the default TTS voice.""" return self._prefs.get(PREF_TTS_DEFAULT_VOICE, DEFAULT_TTS_DEFAULT_VOICE) @@ -281,7 +292,7 @@ class CloudPreferences: # an image was restored without restoring the cloud prefs. return await self._hass.auth.async_get_user(user_id) - async def _save_prefs(self, prefs): + async def _save_prefs(self, prefs: dict[str, Any]) -> None: """Save preferences to disk.""" self.last_updated = { key for key, value in prefs.items() if value != self._prefs.get(key) @@ -294,7 +305,7 @@ class CloudPreferences: @callback @staticmethod - def _empty_config(username): + def _empty_config(username: str) -> dict[str, Any]: """Return an empty config.""" return { PREF_ALEXA_DEFAULT_EXPOSE: DEFAULT_EXPOSED_DOMAINS, diff --git a/homeassistant/components/cloud/repairs.py b/homeassistant/components/cloud/repairs.py index 0864d8b48ad7..f7368731d926 100644 --- a/homeassistant/components/cloud/repairs.py +++ b/homeassistant/components/cloud/repairs.py @@ -12,6 +12,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import issue_registry as ir +from .client import CloudClient from .const import DOMAIN from .subscription import async_migrate_paypal_agreement, async_subscription_info @@ -67,7 +68,7 @@ class LegacySubscriptionRepairFlow(RepairsFlow): async def async_step_change_plan(self, _: None = None) -> FlowResult: """Wait for the user to authorize the app installation.""" - cloud: Cloud = self.hass.data[DOMAIN] + cloud: Cloud[CloudClient] = self.hass.data[DOMAIN] async def _async_wait_for_plan_change() -> None: flow_manager = repairs_flow_manager(self.hass) diff --git a/homeassistant/components/cloud/stt.py b/homeassistant/components/cloud/stt.py index 84e1e088d47d..7b6da8b74039 100644 --- a/homeassistant/components/cloud/stt.py +++ b/homeassistant/components/cloud/stt.py @@ -18,15 +18,22 @@ from homeassistant.components.stt import ( SpeechResult, SpeechResultState, ) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from .client import CloudClient from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -async def async_get_engine(hass, config, discovery_info=None): +async def async_get_engine( + hass: HomeAssistant, + config: ConfigType, + discovery_info: DiscoveryInfoType | None = None, +) -> CloudProvider: """Set up Cloud speech component.""" - cloud: Cloud = hass.data[DOMAIN] + cloud: Cloud[CloudClient] = hass.data[DOMAIN] cloud_provider = CloudProvider(cloud) if discovery_info is not None: @@ -37,7 +44,7 @@ async def async_get_engine(hass, config, discovery_info=None): class CloudProvider(Provider): """NabuCasa speech API provider.""" - def __init__(self, cloud: Cloud) -> None: + def __init__(self, cloud: Cloud[CloudClient]) -> None: """Home Assistant NabuCasa Speech to text.""" self.cloud = cloud diff --git a/homeassistant/components/cloud/subscription.py b/homeassistant/components/cloud/subscription.py index b85a50b20cd4..633f0c95e1b7 100644 --- a/homeassistant/components/cloud/subscription.py +++ b/homeassistant/components/cloud/subscription.py @@ -9,12 +9,13 @@ from aiohttp.client_exceptions import ClientError import async_timeout from hass_nabucasa import Cloud, cloud_api +from .client import CloudClient from .const import REQUEST_TIMEOUT _LOGGER = logging.getLogger(__name__) -async def async_subscription_info(cloud: Cloud) -> dict[str, Any] | None: +async def async_subscription_info(cloud: Cloud[CloudClient]) -> dict[str, Any] | None: """Fetch the subscription info.""" try: async with async_timeout.timeout(REQUEST_TIMEOUT): @@ -33,7 +34,9 @@ async def async_subscription_info(cloud: Cloud) -> dict[str, Any] | None: return None -async def async_migrate_paypal_agreement(cloud: Cloud) -> dict[str, Any] | None: +async def async_migrate_paypal_agreement( + cloud: Cloud[CloudClient], +) -> dict[str, Any] | None: """Migrate a paypal agreement from legacy.""" try: async with async_timeout.timeout(REQUEST_TIMEOUT): diff --git a/homeassistant/components/cloud/system_health.py b/homeassistant/components/cloud/system_health.py index 592338144f36..0dfd69344f3e 100644 --- a/homeassistant/components/cloud/system_health.py +++ b/homeassistant/components/cloud/system_health.py @@ -1,4 +1,6 @@ """Provide info to system health.""" +from typing import Any + from hass_nabucasa import Cloud from homeassistant.components import system_health @@ -16,12 +18,12 @@ def async_register( register.async_register_info(system_health_info, "/config/cloud") -async def system_health_info(hass): +async def system_health_info(hass: HomeAssistant) -> dict[str, Any]: """Get info for the info page.""" - cloud: Cloud = hass.data[DOMAIN] - client: CloudClient = cloud.client + cloud: Cloud[CloudClient] = hass.data[DOMAIN] + client = cloud.client - data = { + data: dict[str, Any] = { "logged_in": cloud.is_logged_in, } diff --git a/homeassistant/components/cloud/tts.py b/homeassistant/components/cloud/tts.py index fea2ffca9873..014b5c75d4c6 100644 --- a/homeassistant/components/cloud/tts.py +++ b/homeassistant/components/cloud/tts.py @@ -1,6 +1,8 @@ """Support for the cloud for text to speech service.""" +from __future__ import annotations import logging +from typing import Any from hass_nabucasa import Cloud from hass_nabucasa.voice import MAP_VOICE, TTS_VOICES, AudioOutput, VoiceError @@ -12,11 +14,15 @@ from homeassistant.components.tts import ( CONF_LANG, PLATFORM_SCHEMA, Provider, + TtsAudioType, Voice, ) -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from .client import CloudClient from .const import DOMAIN +from .prefs import CloudPreferences ATTR_GENDER = "gender" @@ -25,7 +31,7 @@ SUPPORT_LANGUAGES = list(TTS_VOICES) _LOGGER = logging.getLogger(__name__) -def validate_lang(value): +def validate_lang(value: dict[str, Any]) -> dict[str, Any]: """Validate chosen gender or language.""" if (lang := value.get(CONF_LANG)) is None: return value @@ -52,10 +58,16 @@ PLATFORM_SCHEMA = vol.All( ) -async def async_get_engine(hass, config, discovery_info=None): +async def async_get_engine( + hass: HomeAssistant, + config: ConfigType, + discovery_info: DiscoveryInfoType | None = None, +) -> CloudProvider: """Set up Cloud speech component.""" - cloud: Cloud = hass.data[DOMAIN] + cloud: Cloud[CloudClient] = hass.data[DOMAIN] + language: str | None + gender: str | None if discovery_info is not None: language = None gender = None @@ -72,7 +84,9 @@ async def async_get_engine(hass, config, discovery_info=None): class CloudProvider(Provider): """NabuCasa Cloud speech API provider.""" - def __init__(self, cloud: Cloud, language: str, gender: str) -> None: + def __init__( + self, cloud: Cloud[CloudClient], language: str | None, gender: str | None + ) -> None: """Initialize cloud provider.""" self.cloud = cloud self.name = "Cloud" @@ -85,22 +99,22 @@ class CloudProvider(Provider): self._language, self._gender = cloud.client.prefs.tts_default_voice cloud.client.prefs.async_listen_updates(self._sync_prefs) - async def _sync_prefs(self, prefs): + async def _sync_prefs(self, prefs: CloudPreferences) -> None: """Sync preferences.""" self._language, self._gender = prefs.tts_default_voice @property - def default_language(self): + def default_language(self) -> str | None: """Return the default language.""" return self._language @property - def supported_languages(self): + def supported_languages(self) -> list[str]: """Return list of supported languages.""" return SUPPORT_LANGUAGES @property - def supported_options(self): + def supported_options(self) -> list[str]: """Return list of supported options like voice, emotion.""" return [ATTR_GENDER, ATTR_VOICE, ATTR_AUDIO_OUTPUT] @@ -112,17 +126,20 @@ class CloudProvider(Provider): return [Voice(voice, voice) for voice in voices] @property - def default_options(self): + def default_options(self) -> dict[str, Any]: """Return a dict include default options.""" return { ATTR_GENDER: self._gender, ATTR_AUDIO_OUTPUT: AudioOutput.MP3, } - async def async_get_tts_audio(self, message, language, options=None): + async def async_get_tts_audio( + self, message: str, language: str, options: dict[str, Any] | None = None + ) -> TtsAudioType: """Load TTS from NabuCasa Cloud.""" # Process TTS try: + assert options is not None data = await self.cloud.voice.process_tts( text=message, language=language, diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index e194242df91a..49d130d66569 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -21,7 +21,7 @@ from homeassistant.const import ( CONF_NAME, STATE_UNAVAILABLE, ) -from homeassistant.core import Context, HomeAssistant, State, callback +from homeassistant.core import CALLBACK_TYPE, Context, HomeAssistant, State, callback from homeassistant.helpers import ( area_registry as ar, device_registry as dr, @@ -86,19 +86,19 @@ def _get_registry_entries( class AbstractConfig(ABC): """Hold the configuration for Google Assistant.""" + _store: GoogleConfigStore _unsub_report_state: Callable[[], None] | None = None - def __init__(self, hass): + def __init__(self, hass: HomeAssistant) -> None: """Initialize abstract config.""" self.hass = hass - self._store = None - self._google_sync_unsub = {} + self._google_sync_unsub: dict[str, CALLBACK_TYPE] = {} self._local_sdk_active = False self._local_last_active: datetime | None = None self._local_sdk_version_warn = False self.is_supported_cache: dict[str, tuple[int | None, bool]] = {} - async def async_initialize(self): + async def async_initialize(self) -> None: """Perform async initialization of config.""" self._store = GoogleConfigStore(self.hass) await self._store.async_initialize() @@ -195,7 +195,7 @@ class AbstractConfig(ABC): await gather(*jobs) @callback - def async_enable_report_state(self): + def async_enable_report_state(self) -> None: """Enable proactive mode.""" # Circular dep # pylint: disable-next=import-outside-toplevel @@ -205,7 +205,7 @@ class AbstractConfig(ABC): self._unsub_report_state = async_enable_report_state(self.hass, self) @callback - def async_disable_report_state(self): + def async_disable_report_state(self) -> None: """Disable report state.""" if self._unsub_report_state is not None: self._unsub_report_state() @@ -220,7 +220,7 @@ class AbstractConfig(ABC): await self.async_disconnect_agent_user(agent_user_id) return status - async def async_sync_entities_all(self): + async def async_sync_entities_all(self) -> int: """Sync all entities to Google for all registered agents.""" if not self._store.agent_user_ids: return 204 @@ -249,7 +249,7 @@ class AbstractConfig(ABC): ) @callback - def async_schedule_google_sync_all(self): + def async_schedule_google_sync_all(self) -> None: """Schedule a sync for all registered agents.""" for agent_user_id in self._store.agent_user_ids: self.async_schedule_google_sync(agent_user_id) @@ -279,7 +279,7 @@ class AbstractConfig(ABC): self._store.pop_agent_user_id(agent_user_id) @callback - def async_enable_local_sdk(self): + def async_enable_local_sdk(self) -> None: """Enable the local SDK.""" setup_successful = True setup_webhook_ids = [] @@ -323,7 +323,7 @@ class AbstractConfig(ABC): self._local_sdk_active = setup_successful @callback - def async_disable_local_sdk(self): + def async_disable_local_sdk(self) -> None: """Disable the local SDK.""" if not self._local_sdk_active: return @@ -500,7 +500,7 @@ class GoogleEntity: self.hass = hass self.config = config self.state = state - self._traits = None + self._traits: list[trait._Trait] | None = None @property def entity_id(self): @@ -508,7 +508,7 @@ class GoogleEntity: return self.state.entity_id @callback - def traits(self): + def traits(self) -> list[trait._Trait]: """Return traits for entity.""" if self._traits is not None: return self._traits diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index 3752574f31f1..a075a251dbaf 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -1,6 +1,7 @@ """Implement the Google Smart Home traits.""" from __future__ import annotations +from abc import ABC, abstractmethod import logging from typing import Any, TypeVar @@ -196,9 +197,10 @@ def _next_selected(items: list[str], selected: str | None) -> str | None: return items[next_item] -class _Trait: +class _Trait(ABC): """Represents a Trait inside Google Assistant skill.""" + name: str commands: list[str] = [] @staticmethod @@ -206,6 +208,11 @@ class _Trait: """Return if the trait might ask for 2FA.""" return False + @staticmethod + @abstractmethod + def supported(domain, features, device_class, attributes): + """Test if state is supported.""" + def __init__(self, hass, state, config): """Initialize a trait for a state.""" self.hass = hass diff --git a/mypy.ini b/mypy.ini index b2c6b7a9c835..feb6f5a72ea8 100644 --- a/mypy.ini +++ b/mypy.ini @@ -631,6 +631,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.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.configurator.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/tests/components/cloud/test_init.py b/tests/components/cloud/test_init.py index bd0a4972241e..f56789729d8c 100644 --- a/tests/components/cloud/test_init.py +++ b/tests/components/cloud/test_init.py @@ -135,7 +135,7 @@ async def test_setup_existing_cloud_user( async def test_on_connect(hass: HomeAssistant, mock_cloud_fixture) -> None: """Test cloud on connect triggers.""" - cl: Cloud = hass.data["cloud"] + cl: Cloud[cloud.client.CloudClient] = hass.data["cloud"] assert len(cl.iot._on_connect) == 4 From 2a7ba6057417826c64f011a113b41d5d9e0ce913 Mon Sep 17 00:00:00 2001 From: Mike Heath Date: Wed, 24 May 2023 04:32:26 -0600 Subject: [PATCH 505/822] Fully Kiosk: Truncate long URLs (#92347) * Truncate long URLs URL's longer than 256 characters will result in a `homeassistant.exceptions.InvalidStateError`. This fixes that problem and adds 2 extra_state_attributes: `full_url`, and `truncated`. Fixes #89249 * Refactor to use function ref in entity description --- .../components/fully_kiosk/__init__.py | 1 + .../components/fully_kiosk/sensor.py | 60 +++++++++++++------ tests/components/fully_kiosk/test_sensor.py | 30 ++++++++++ 3 files changed, 72 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/fully_kiosk/__init__.py b/homeassistant/components/fully_kiosk/__init__.py index e417d7c0bcb0..dd1cc70c9f48 100644 --- a/homeassistant/components/fully_kiosk/__init__.py +++ b/homeassistant/components/fully_kiosk/__init__.py @@ -26,6 +26,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + coordinator.async_update_listeners() await async_setup_services(hass) diff --git a/homeassistant/components/fully_kiosk/sensor.py b/homeassistant/components/fully_kiosk/sensor.py index 60009eb6ae4d..eed14f246743 100644 --- a/homeassistant/components/fully_kiosk/sensor.py +++ b/homeassistant/components/fully_kiosk/sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from typing import Any from homeassistant.components.sensor import ( SensorDeviceClass, @@ -12,7 +13,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfInformation -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -26,11 +27,25 @@ def round_storage(value: int) -> float: return round(value * 0.000001, 1) +def truncate_url(value: StateType) -> tuple[StateType, dict[str, Any]]: + """Truncate URL if longer than 256.""" + url = str(value) + truncated = len(url) > 256 + extra_state_attributes = { + "full_url": url, + "truncated": truncated, + } + if truncated: + return (url[0:255], extra_state_attributes) + return (url, extra_state_attributes) + + @dataclass class FullySensorEntityDescription(SensorEntityDescription): """Fully Kiosk Browser sensor description.""" - state_fn: Callable[[int], float] | None = None + round_state_value: bool = False + state_fn: Callable[[StateType], tuple[StateType, dict[str, Any]]] | None = None SENSORS: tuple[FullySensorEntityDescription, ...] = ( @@ -42,6 +57,12 @@ SENSORS: tuple[FullySensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), + FullySensorEntityDescription( + key="currentPage", + name="Current page", + entity_category=EntityCategory.DIAGNOSTIC, + state_fn=truncate_url, + ), FullySensorEntityDescription( key="screenOrientation", name="Screen orientation", @@ -52,11 +73,6 @@ SENSORS: tuple[FullySensorEntityDescription, ...] = ( name="Foreground app", entity_category=EntityCategory.DIAGNOSTIC, ), - FullySensorEntityDescription( - key="currentPage", - name="Current page", - entity_category=EntityCategory.DIAGNOSTIC, - ), FullySensorEntityDescription( key="internalStorageFreeSpace", name="Internal storage free space", @@ -64,7 +80,7 @@ SENSORS: tuple[FullySensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, state_class=SensorStateClass.MEASUREMENT, - state_fn=round_storage, + round_state_value=True, ), FullySensorEntityDescription( key="internalStorageTotalSpace", @@ -73,7 +89,7 @@ SENSORS: tuple[FullySensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, state_class=SensorStateClass.MEASUREMENT, - state_fn=round_storage, + round_state_value=True, ), FullySensorEntityDescription( key="ramFreeMemory", @@ -82,7 +98,7 @@ SENSORS: tuple[FullySensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, state_class=SensorStateClass.MEASUREMENT, - state_fn=round_storage, + round_state_value=True, ), FullySensorEntityDescription( key="ramTotalMemory", @@ -91,7 +107,7 @@ SENSORS: tuple[FullySensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, state_class=SensorStateClass.MEASUREMENT, - state_fn=round_storage, + round_state_value=True, ), ) @@ -129,13 +145,19 @@ class FullySensor(FullyKioskEntity, SensorEntity): super().__init__(coordinator) - @property - def native_value(self) -> StateType: - """Return the state of the sensor.""" - if (value := self.coordinator.data.get(self.entity_description.key)) is None: - return None + @callback + def _handle_coordinator_update(self) -> None: + extra_state_attributes: dict[str, Any] = {} + value = self.coordinator.data.get(self.entity_description.key) - if self.entity_description.state_fn is not None: - return self.entity_description.state_fn(value) + if value is not None: + if self.entity_description.state_fn is not None: + value, extra_state_attributes = self.entity_description.state_fn(value) - return value # type: ignore[no-any-return] + if self.entity_description.round_state_value: + value = round_storage(value) + + self._attr_native_value = value + self._attr_extra_state_attributes = extra_state_attributes + + self.async_write_ha_state() diff --git a/tests/components/fully_kiosk/test_sensor.py b/tests/components/fully_kiosk/test_sensor.py index c7ff09fe32ce..6454784a2517 100644 --- a/tests/components/fully_kiosk/test_sensor.py +++ b/tests/components/fully_kiosk/test_sensor.py @@ -66,6 +66,8 @@ async def test_sensors_sensors( assert state.state == "https://homeassistant.local" assert state.attributes.get(ATTR_DEVICE_CLASS) is None assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Amazon Fire Current page" + assert state.attributes.get("full_url") == "https://homeassistant.local" + assert not state.attributes.get("truncated") entry = entity_registry.async_get("sensor.amazon_fire_current_page") assert entry @@ -154,3 +156,31 @@ async def test_sensors_sensors( state = hass.states.get("sensor.amazon_fire_internal_storage_free_space") assert state assert state.state == STATE_UNAVAILABLE + + +async def test_url_sensor_truncating( + hass: HomeAssistant, + mock_fully_kiosk: MagicMock, + init_integration: MockConfigEntry, +) -> None: + """Test that long URLs get truncated.""" + state = hass.states.get("sensor.amazon_fire_current_page") + assert state + assert state.state == "https://homeassistant.local" + assert state.attributes.get("full_url") == "https://homeassistant.local" + assert not state.attributes.get("truncated") + + long_url = "https://01234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789" + assert len(long_url) > 256 + + mock_fully_kiosk.getDeviceInfo.return_value = { + "currentPage": long_url, + } + async_fire_time_changed(hass, dt.utcnow() + UPDATE_INTERVAL) + await hass.async_block_till_done() + + state = hass.states.get("sensor.amazon_fire_current_page") + assert state + assert state.state == long_url[0:255] + assert state.attributes.get("full_url") == long_url + assert state.attributes.get("truncated") From 0ca0ed3a315d427071919da0d7c9e5ad76ce9a7d Mon Sep 17 00:00:00 2001 From: Tom Schneider Date: Wed, 24 May 2023 12:43:36 +0200 Subject: [PATCH 506/822] Bump pygti and change the request for the new api version (#92283) Bump pygti and change request for the new api version --- homeassistant/components/hvv_departures/binary_sensor.py | 2 +- homeassistant/components/hvv_departures/manifest.json | 2 +- homeassistant/components/hvv_departures/sensor.py | 4 +++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 7 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/hvv_departures/binary_sensor.py b/homeassistant/components/hvv_departures/binary_sensor.py index 36b1b5f927fa..a50b2c4d09b6 100644 --- a/homeassistant/components/hvv_departures/binary_sensor.py +++ b/homeassistant/components/hvv_departures/binary_sensor.py @@ -88,7 +88,7 @@ async def async_setup_entry( so entities can quickly look up their data. """ - payload = {"station": station} + payload = {"station": {"id": station["id"], "type": station["type"]}} try: async with async_timeout.timeout(10): diff --git a/homeassistant/components/hvv_departures/manifest.json b/homeassistant/components/hvv_departures/manifest.json index c6d3060b4ee7..c18777613e81 100644 --- a/homeassistant/components/hvv_departures/manifest.json +++ b/homeassistant/components/hvv_departures/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/hvv_departures", "iot_class": "cloud_polling", "loggers": ["pygti"], - "requirements": ["pygti==0.9.3"] + "requirements": ["pygti==0.9.4"] } diff --git a/homeassistant/components/hvv_departures/sensor.py b/homeassistant/components/hvv_departures/sensor.py index dfc69e517101..0669289c1bb4 100644 --- a/homeassistant/components/hvv_departures/sensor.py +++ b/homeassistant/components/hvv_departures/sensor.py @@ -84,8 +84,10 @@ class HVVDepartureSensor(SensorEntity): departure_time_tz_berlin = departure_time.astimezone(BERLIN_TIME_ZONE) + station = self.config_entry.data[CONF_STATION] + payload = { - "station": self.config_entry.data[CONF_STATION], + "station": {"id": station["id"], "type": station["type"]}, "time": { "date": departure_time_tz_berlin.strftime("%d.%m.%Y"), "time": departure_time_tz_berlin.strftime("%H:%M"), diff --git a/requirements_all.txt b/requirements_all.txt index 71fca46bb3a9..b66ad209649c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1671,7 +1671,7 @@ pygatt[GATTTOOL]==4.0.5 pygtfs==0.1.7 # homeassistant.components.hvv_departures -pygti==0.9.3 +pygti==0.9.4 # homeassistant.components.version pyhaversion==22.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1e315192b803..4bc1e13519a2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1226,7 +1226,7 @@ pyfronius==0.7.1 pyfttt==0.3 # homeassistant.components.hvv_departures -pygti==0.9.3 +pygti==0.9.4 # homeassistant.components.version pyhaversion==22.8.0 From db33aadcbb756b415700633715ee9623dc8d1544 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 24 May 2023 12:48:55 +0200 Subject: [PATCH 507/822] Implement OpenSky library (#92814) * Implement OpenSky library * Move function to library * Fix feedback --- CODEOWNERS | 1 + .../components/opensky/manifest.json | 5 +- homeassistant/components/opensky/sensor.py | 72 +++++++++---------- requirements_all.txt | 3 + 4 files changed, 42 insertions(+), 39 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 8a4e095e14b1..9954bdc3a2d8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -872,6 +872,7 @@ build.json @home-assistant/supervisor /homeassistant/components/opengarage/ @danielhiversen /tests/components/opengarage/ @danielhiversen /homeassistant/components/openhome/ @bazwilliams +/homeassistant/components/opensky/ @joostlek /homeassistant/components/opentherm_gw/ @mvn23 /tests/components/opentherm_gw/ @mvn23 /homeassistant/components/openuv/ @bachya diff --git a/homeassistant/components/opensky/manifest.json b/homeassistant/components/opensky/manifest.json index 7fdb6cfd677e..854f2ec840bb 100644 --- a/homeassistant/components/opensky/manifest.json +++ b/homeassistant/components/opensky/manifest.json @@ -1,7 +1,8 @@ { "domain": "opensky", "name": "OpenSky Network", - "codeowners": [], + "codeowners": ["@joostlek"], "documentation": "https://www.home-assistant.io/integrations/opensky", - "iot_class": "cloud_polling" + "iot_class": "cloud_polling", + "requirements": ["python-opensky==0.0.7"] } diff --git a/homeassistant/components/opensky/sensor.py b/homeassistant/components/opensky/sensor.py index 03e242f40b29..f422eb150a08 100644 --- a/homeassistant/components/opensky/sensor.py +++ b/homeassistant/components/opensky/sensor.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import timedelta -import requests +from python_opensky import BoundingBox, OpenSky import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity @@ -14,14 +14,12 @@ from homeassistant.const import ( CONF_LONGITUDE, CONF_NAME, CONF_RADIUS, - UnitOfLength, ) from homeassistant.core import HomeAssistant +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.util import location as util_location -from homeassistant.util.unit_conversion import DistanceConverter CONF_ALTITUDE = "altitude" @@ -79,15 +77,18 @@ def setup_platform( """Set up the Open Sky platform.""" latitude = config.get(CONF_LATITUDE, hass.config.latitude) longitude = config.get(CONF_LONGITUDE, hass.config.longitude) + radius = config.get(CONF_RADIUS, 0) + bounding_box = OpenSky.get_bounding_box(latitude, longitude, radius) + session = async_get_clientsession(hass) + opensky = OpenSky(session=session) add_entities( [ OpenSkySensor( hass, config.get(CONF_NAME, DOMAIN), - latitude, - longitude, - config.get(CONF_RADIUS), - config.get(CONF_ALTITUDE), + opensky, + bounding_box, + config[CONF_ALTITUDE], ) ], True, @@ -101,19 +102,22 @@ class OpenSkySensor(SensorEntity): "Information provided by the OpenSky Network (https://opensky-network.org)" ) - def __init__(self, hass, name, latitude, longitude, radius, altitude): + def __init__( + self, + hass: HomeAssistant, + name: str, + opensky: OpenSky, + bounding_box: BoundingBox, + altitude: float, + ) -> None: """Initialize the sensor.""" - self._session = requests.Session() - self._latitude = latitude - self._longitude = longitude - self._radius = DistanceConverter.convert( - radius, UnitOfLength.KILOMETERS, UnitOfLength.METERS - ) self._altitude = altitude self._state = 0 self._hass = hass self._name = name - self._previously_tracked = None + self._previously_tracked: set[str] = set() + self._opensky = opensky + self._bounding_box = bounding_box @property def name(self): @@ -129,10 +133,10 @@ class OpenSkySensor(SensorEntity): """Handle flights crossing region boundary.""" for flight in flights: if flight in metadata: - altitude = metadata[flight].get(ATTR_ALTITUDE) - longitude = metadata[flight].get(ATTR_LONGITUDE) - latitude = metadata[flight].get(ATTR_LATITUDE) - icao24 = metadata[flight].get(ATTR_ICAO24) + altitude = metadata[flight].barometric_altitude + longitude = metadata[flight].longitude + latitude = metadata[flight].latitude + icao24 = metadata[flight].icao24 else: # Assume Flight has landed if missing. altitude = 0 @@ -150,33 +154,27 @@ class OpenSkySensor(SensorEntity): } self._hass.bus.fire(event, data) - def update(self) -> None: + async def async_update(self) -> None: """Update device state.""" currently_tracked = set() flight_metadata = {} - states = self._session.get(OPENSKY_API_URL).json().get(ATTR_STATES) - for state in states: - flight = dict(zip(OPENSKY_API_FIELDS, state)) - callsign = flight[ATTR_CALLSIGN].strip() + response = await self._opensky.get_states(bounding_box=self._bounding_box) + for flight in response.states: + if not flight.callsign: + continue + callsign = flight.callsign.strip() if callsign != "": flight_metadata[callsign] = flight else: continue if ( - (longitude := flight.get(ATTR_LONGITUDE)) is None - or (latitude := flight.get(ATTR_LATITUDE)) is None - or flight.get(ATTR_ON_GROUND) + flight.longitude is None + or flight.latitude is None + or flight.on_ground + or flight.barometric_altitude is None ): continue - distance = util_location.distance( - self._latitude, - self._longitude, - latitude, - longitude, - ) - if distance is None or distance > self._radius: - continue - altitude = flight.get(ATTR_ALTITUDE) + altitude = flight.barometric_altitude if altitude > self._altitude and self._altitude != 0: continue currently_tracked.add(callsign) diff --git a/requirements_all.txt b/requirements_all.txt index b66ad209649c..80999fc96bb1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2103,6 +2103,9 @@ python-mystrom==2.2.0 # homeassistant.components.nest python-nest==4.2.0 +# homeassistant.components.opensky +python-opensky==0.0.7 + # homeassistant.components.otbr # homeassistant.components.thread python-otbr-api==1.0.9 From c475273145e9e966b2b3c2d79542efd0a0f63f11 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 24 May 2023 12:49:35 +0200 Subject: [PATCH 508/822] Prevent SensorEntity and RestoreEntity inheritance (#88971) --- homeassistant/components/august/sensor.py | 1 + homeassistant/components/enocean/sensor.py | 5 ++ .../components/eufylife_ble/sensor.py | 2 + homeassistant/components/fastdotcom/sensor.py | 1 + .../components/fireservicerota/sensor.py | 1 + .../components/integration/sensor.py | 1 + homeassistant/components/iperf3/sensor.py | 1 + homeassistant/components/rfxtrx/sensor.py | 1 + homeassistant/components/shelly/sensor.py | 2 + .../components/smart_meter_texas/sensor.py | 1 + .../components/speedtestdotnet/sensor.py | 1 + homeassistant/components/zha/sensor.py | 44 +++++++++++++ pylint/plugins/hass_inheritance.py | 66 +++++++++++++++++++ pyproject.toml | 1 + 14 files changed, 128 insertions(+) create mode 100644 pylint/plugins/hass_inheritance.py diff --git a/homeassistant/components/august/sensor.py b/homeassistant/components/august/sensor.py index 6e0969a6724c..169a344e2bd0 100644 --- a/homeassistant/components/august/sensor.py +++ b/homeassistant/components/august/sensor.py @@ -172,6 +172,7 @@ async def _async_migrate_old_unique_ids(hass, devices): registry.async_update_entity(old_entity_id, new_unique_id=device.unique_id) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class AugustOperatorSensor(AugustEntityMixin, RestoreEntity, SensorEntity): """Representation of an August lock operation sensor.""" diff --git a/homeassistant/components/enocean/sensor.py b/homeassistant/components/enocean/sensor.py index 4fa3b25ed06a..5d1c00277915 100644 --- a/homeassistant/components/enocean/sensor.py +++ b/homeassistant/components/enocean/sensor.py @@ -151,6 +151,7 @@ def setup_platform( add_entities(entities) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class EnOceanSensor(EnOceanEntity, RestoreEntity, SensorEntity): """Representation of an EnOcean sensor device such as a power meter.""" @@ -180,6 +181,7 @@ class EnOceanSensor(EnOceanEntity, RestoreEntity, SensorEntity): """Update the internal state of the sensor.""" +# pylint: disable-next=hass-invalid-inheritance # needs fixing class EnOceanPowerSensor(EnOceanSensor): """Representation of an EnOcean power sensor. @@ -200,6 +202,7 @@ class EnOceanPowerSensor(EnOceanSensor): self.schedule_update_ha_state() +# pylint: disable-next=hass-invalid-inheritance # needs fixing class EnOceanTemperatureSensor(EnOceanSensor): """Representation of an EnOcean temperature sensor device. @@ -249,6 +252,7 @@ class EnOceanTemperatureSensor(EnOceanSensor): self.schedule_update_ha_state() +# pylint: disable-next=hass-invalid-inheritance # needs fixing class EnOceanHumiditySensor(EnOceanSensor): """Representation of an EnOcean humidity sensor device. @@ -267,6 +271,7 @@ class EnOceanHumiditySensor(EnOceanSensor): self.schedule_update_ha_state() +# pylint: disable-next=hass-invalid-inheritance # needs fixing class EnOceanWindowHandle(EnOceanSensor): """Representation of an EnOcean window handle device. diff --git a/homeassistant/components/eufylife_ble/sensor.py b/homeassistant/components/eufylife_ble/sensor.py index e57b83687a62..d7c69dec165a 100644 --- a/homeassistant/components/eufylife_ble/sensor.py +++ b/homeassistant/components/eufylife_ble/sensor.py @@ -111,6 +111,7 @@ class EufyLifeRealTimeWeightSensorEntity(EufyLifeSensorEntity): return UnitOfMass.KILOGRAMS +# pylint: disable-next=hass-invalid-inheritance # needs fixing class EufyLifeWeightSensorEntity(RestoreEntity, EufyLifeSensorEntity): """Representation of an EufyLife weight sensor.""" @@ -171,6 +172,7 @@ class EufyLifeWeightSensorEntity(RestoreEntity, EufyLifeSensorEntity): ) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class EufyLifeHeartRateSensorEntity(RestoreEntity, EufyLifeSensorEntity): """Representation of an EufyLife heart rate sensor.""" diff --git a/homeassistant/components/fastdotcom/sensor.py b/homeassistant/components/fastdotcom/sensor.py index 4f00dd5a5439..b3d5f66ae8c8 100644 --- a/homeassistant/components/fastdotcom/sensor.py +++ b/homeassistant/components/fastdotcom/sensor.py @@ -24,6 +24,7 @@ async def async_setup_platform( async_add_entities([SpeedtestSensor(hass.data[FASTDOTCOM_DOMAIN])]) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class SpeedtestSensor(RestoreEntity, SensorEntity): """Implementation of a FAst.com sensor.""" diff --git a/homeassistant/components/fireservicerota/sensor.py b/homeassistant/components/fireservicerota/sensor.py index 1484ff7f1543..20f8589d2a2f 100644 --- a/homeassistant/components/fireservicerota/sensor.py +++ b/homeassistant/components/fireservicerota/sensor.py @@ -23,6 +23,7 @@ async def async_setup_entry( async_add_entities([IncidentsSensor(client)]) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class IncidentsSensor(RestoreEntity, SensorEntity): """Representation of FireServiceRota incidents sensor.""" diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index 64d83506ad95..7e60f2c509ca 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -128,6 +128,7 @@ async def async_setup_platform( async_add_entities([integral]) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class IntegrationSensor(RestoreEntity, SensorEntity): """Representation of an integration sensor.""" diff --git a/homeassistant/components/iperf3/sensor.py b/homeassistant/components/iperf3/sensor.py index e39d1e1d864c..d3db0e766314 100644 --- a/homeassistant/components/iperf3/sensor.py +++ b/homeassistant/components/iperf3/sensor.py @@ -35,6 +35,7 @@ async def async_setup_platform( async_add_entities(entities, True) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class Iperf3Sensor(RestoreEntity, SensorEntity): """A Iperf3 sensor implementation.""" diff --git a/homeassistant/components/rfxtrx/sensor.py b/homeassistant/components/rfxtrx/sensor.py index e594b47c93a5..3613a640f1ab 100644 --- a/homeassistant/components/rfxtrx/sensor.py +++ b/homeassistant/components/rfxtrx/sensor.py @@ -281,6 +281,7 @@ async def async_setup_entry( ) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class RfxtrxSensor(RfxtrxEntity, SensorEntity): """Representation of a RFXtrx sensor. diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index b83131a1004c..ce77a6e9ff66 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -768,6 +768,7 @@ class RpcSensor(ShellyRpcAttributeEntity, SensorEntity): return self.attribute_value +# pylint: disable-next=hass-invalid-inheritance # needs fixing class BlockSleepingSensor(ShellySleepingBlockAttributeEntity, SensorEntity): """Represent a block sleeping sensor.""" @@ -808,6 +809,7 @@ class BlockSleepingSensor(ShellySleepingBlockAttributeEntity, SensorEntity): return self.last_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class RpcSleepingSensor(ShellySleepingRpcAttributeEntity, SensorEntity): """Represent a RPC sleeping sensor.""" diff --git a/homeassistant/components/smart_meter_texas/sensor.py b/homeassistant/components/smart_meter_texas/sensor.py index 57a88f7a409f..7552f2c0697d 100644 --- a/homeassistant/components/smart_meter_texas/sensor.py +++ b/homeassistant/components/smart_meter_texas/sensor.py @@ -40,6 +40,7 @@ async def async_setup_entry( ) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class SmartMeterTexasSensor(CoordinatorEntity, RestoreEntity, SensorEntity): """Representation of an Smart Meter Texas sensor.""" diff --git a/homeassistant/components/speedtestdotnet/sensor.py b/homeassistant/components/speedtestdotnet/sensor.py index c37738026503..d44d66bbd477 100644 --- a/homeassistant/components/speedtestdotnet/sensor.py +++ b/homeassistant/components/speedtestdotnet/sensor.py @@ -78,6 +78,7 @@ async def async_setup_entry( ) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class SpeedtestSensor( CoordinatorEntity[SpeedTestDataCoordinator], RestoreEntity, SensorEntity ): diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index 52c1f6a5b193..da1f1f6c04c3 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -115,6 +115,7 @@ async def async_setup_entry( config_entry.async_on_unload(unsub) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class Sensor(ZhaEntity, SensorEntity): """Base ZHA sensor.""" @@ -187,6 +188,7 @@ class Sensor(ZhaEntity, SensorEntity): manufacturers="Digi", stop_on_match_group=CLUSTER_HANDLER_ANALOG_INPUT, ) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class AnalogInput(Sensor): """Sensor that displays analog input values.""" @@ -195,6 +197,7 @@ class AnalogInput(Sensor): @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_POWER_CONFIGURATION) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class Battery(Sensor): """Battery sensor of power configuration cluster.""" @@ -253,6 +256,7 @@ class Battery(Sensor): stop_on_match_group=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT, models={"VZM31-SN", "SP 234", "outletv4"}, ) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class ElectricalMeasurement(Sensor): """Active power measurement.""" @@ -292,6 +296,7 @@ class ElectricalMeasurement(Sensor): cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT, stop_on_match_group=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT, ) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class PolledElectricalMeasurement(ElectricalMeasurement): """Polled active power measurement.""" @@ -305,6 +310,7 @@ class PolledElectricalMeasurement(ElectricalMeasurement): @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class ElectricalMeasurementApparentPower( ElectricalMeasurement, id_suffix="apparent_power" ): @@ -318,6 +324,7 @@ class ElectricalMeasurementApparentPower( @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class ElectricalMeasurementRMSCurrent(ElectricalMeasurement, id_suffix="rms_current"): """RMS current measurement.""" @@ -329,6 +336,7 @@ class ElectricalMeasurementRMSCurrent(ElectricalMeasurement, id_suffix="rms_curr @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class ElectricalMeasurementRMSVoltage(ElectricalMeasurement, id_suffix="rms_voltage"): """RMS Voltage measurement.""" @@ -340,6 +348,7 @@ class ElectricalMeasurementRMSVoltage(ElectricalMeasurement, id_suffix="rms_volt @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class ElectricalMeasurementFrequency(ElectricalMeasurement, id_suffix="ac_frequency"): """Frequency measurement.""" @@ -351,6 +360,7 @@ class ElectricalMeasurementFrequency(ElectricalMeasurement, id_suffix="ac_freque @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class ElectricalMeasurementPowerFactor(ElectricalMeasurement, id_suffix="power_factor"): """Frequency measurement.""" @@ -368,6 +378,7 @@ class ElectricalMeasurementPowerFactor(ElectricalMeasurement, id_suffix="power_f cluster_handler_names=CLUSTER_HANDLER_HUMIDITY, stop_on_match_group=CLUSTER_HANDLER_HUMIDITY, ) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class Humidity(Sensor): """Humidity sensor.""" @@ -380,6 +391,7 @@ class Humidity(Sensor): @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_SOIL_MOISTURE) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class SoilMoisture(Sensor): """Soil Moisture sensor.""" @@ -392,6 +404,7 @@ class SoilMoisture(Sensor): @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEAF_WETNESS) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class LeafWetness(Sensor): """Leaf Wetness sensor.""" @@ -404,6 +417,7 @@ class LeafWetness(Sensor): @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ILLUMINANCE) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class Illuminance(Sensor): """Illuminance Sensor.""" @@ -422,6 +436,7 @@ class Illuminance(Sensor): cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, stop_on_match_group=CLUSTER_HANDLER_SMARTENERGY_METERING, ) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class SmartEnergyMetering(Sensor): """Metering sensor.""" @@ -475,6 +490,7 @@ class SmartEnergyMetering(Sensor): cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, stop_on_match_group=CLUSTER_HANDLER_SMARTENERGY_METERING, ) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class SmartEnergySummation(SmartEnergyMetering, id_suffix="summation_delivered"): """Smart Energy Metering summation sensor.""" @@ -516,6 +532,7 @@ class SmartEnergySummation(SmartEnergyMetering, id_suffix="summation_delivered") models={"TS011F", "ZLinky_TIC"}, stop_on_match_group=CLUSTER_HANDLER_SMARTENERGY_METERING, ) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class PolledSmartEnergySummation(SmartEnergySummation): """Polled Smart Energy Metering summation sensor.""" @@ -532,6 +549,7 @@ class PolledSmartEnergySummation(SmartEnergySummation): cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, models={"ZLinky_TIC"}, ) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class Tier1SmartEnergySummation( PolledSmartEnergySummation, id_suffix="tier1_summation_delivered" ): @@ -545,6 +563,7 @@ class Tier1SmartEnergySummation( cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, models={"ZLinky_TIC"}, ) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class Tier2SmartEnergySummation( PolledSmartEnergySummation, id_suffix="tier2_summation_delivered" ): @@ -558,6 +577,7 @@ class Tier2SmartEnergySummation( cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, models={"ZLinky_TIC"}, ) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class Tier3SmartEnergySummation( PolledSmartEnergySummation, id_suffix="tier3_summation_delivered" ): @@ -571,6 +591,7 @@ class Tier3SmartEnergySummation( cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, models={"ZLinky_TIC"}, ) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class Tier4SmartEnergySummation( PolledSmartEnergySummation, id_suffix="tier4_summation_delivered" ): @@ -584,6 +605,7 @@ class Tier4SmartEnergySummation( cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, models={"ZLinky_TIC"}, ) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class Tier5SmartEnergySummation( PolledSmartEnergySummation, id_suffix="tier5_summation_delivered" ): @@ -597,6 +619,7 @@ class Tier5SmartEnergySummation( cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, models={"ZLinky_TIC"}, ) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class Tier6SmartEnergySummation( PolledSmartEnergySummation, id_suffix="tier6_summation_delivered" ): @@ -607,6 +630,7 @@ class Tier6SmartEnergySummation( @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_PRESSURE) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class Pressure(Sensor): """Pressure sensor.""" @@ -619,6 +643,7 @@ class Pressure(Sensor): @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_TEMPERATURE) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class Temperature(Sensor): """Temperature Sensor.""" @@ -631,6 +656,7 @@ class Temperature(Sensor): @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_DEVICE_TEMPERATURE) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class DeviceTemperature(Sensor): """Device Temperature Sensor.""" @@ -644,6 +670,7 @@ class DeviceTemperature(Sensor): @MULTI_MATCH(cluster_handler_names="carbon_dioxide_concentration") +# pylint: disable-next=hass-invalid-inheritance # needs fixing class CarbonDioxideConcentration(Sensor): """Carbon Dioxide Concentration sensor.""" @@ -657,6 +684,7 @@ class CarbonDioxideConcentration(Sensor): @MULTI_MATCH(cluster_handler_names="carbon_monoxide_concentration") +# pylint: disable-next=hass-invalid-inheritance # needs fixing class CarbonMonoxideConcentration(Sensor): """Carbon Monoxide Concentration sensor.""" @@ -671,6 +699,7 @@ class CarbonMonoxideConcentration(Sensor): @MULTI_MATCH(generic_ids="cluster_handler_0x042e", stop_on_match_group="voc_level") @MULTI_MATCH(cluster_handler_names="voc_level", stop_on_match_group="voc_level") +# pylint: disable-next=hass-invalid-inheritance # needs fixing class VOCLevel(Sensor): """VOC Level sensor.""" @@ -688,6 +717,7 @@ class VOCLevel(Sensor): models="lumi.airmonitor.acn01", stop_on_match_group="voc_level", ) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class PPBVOCLevel(Sensor): """VOC Level sensor.""" @@ -701,6 +731,7 @@ class PPBVOCLevel(Sensor): @MULTI_MATCH(cluster_handler_names="pm25") +# pylint: disable-next=hass-invalid-inheritance # needs fixing class PM25(Sensor): """Particulate Matter 2.5 microns or less sensor.""" @@ -713,6 +744,7 @@ class PM25(Sensor): @MULTI_MATCH(cluster_handler_names="formaldehyde_concentration") +# pylint: disable-next=hass-invalid-inheritance # needs fixing class FormaldehydeConcentration(Sensor): """Formaldehyde Concentration sensor.""" @@ -728,6 +760,7 @@ class FormaldehydeConcentration(Sensor): cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, stop_on_match_group=CLUSTER_HANDLER_THERMOSTAT, ) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class ThermostatHVACAction(Sensor, id_suffix="hvac_action"): """Thermostat HVAC action sensor.""" @@ -821,6 +854,7 @@ class ThermostatHVACAction(Sensor, id_suffix="hvac_action"): manufacturers="Sinope Technologies", stop_on_match_group=CLUSTER_HANDLER_THERMOSTAT, ) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class SinopeHVACAction(ThermostatHVACAction): """Sinope Thermostat HVAC action sensor.""" @@ -850,6 +884,7 @@ class SinopeHVACAction(ThermostatHVACAction): @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_BASIC) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class RSSISensor(Sensor, id_suffix="rssi"): """RSSI sensor for a device.""" @@ -886,6 +921,7 @@ class RSSISensor(Sensor, id_suffix="rssi"): @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_BASIC) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class LQISensor(RSSISensor, id_suffix="lqi"): """LQI sensor for a device.""" @@ -900,6 +936,7 @@ class LQISensor(RSSISensor, id_suffix="lqi"): "_TZE200_htnnfasr", }, ) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class TimeLeft(Sensor, id_suffix="time_left"): """Sensor that displays time left value.""" @@ -911,6 +948,7 @@ class TimeLeft(Sensor, id_suffix="time_left"): @MULTI_MATCH(cluster_handler_names="ikea_airpurifier") +# pylint: disable-next=hass-invalid-inheritance # needs fixing class IkeaDeviceRunTime(Sensor, id_suffix="device_run_time"): """Sensor that displays device run time (in minutes).""" @@ -922,6 +960,7 @@ class IkeaDeviceRunTime(Sensor, id_suffix="device_run_time"): @MULTI_MATCH(cluster_handler_names="ikea_airpurifier") +# pylint: disable-next=hass-invalid-inheritance # needs fixing class IkeaFilterRunTime(Sensor, id_suffix="filter_run_time"): """Sensor that displays run time of the current filter (in minutes).""" @@ -940,6 +979,7 @@ class AqaraFeedingSource(types.enum8): @MULTI_MATCH(cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"}) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class AqaraPetFeederLastFeedingSource(Sensor, id_suffix="last_feeding_source"): """Sensor that displays the last feeding source of pet feeder.""" @@ -953,6 +993,7 @@ class AqaraPetFeederLastFeedingSource(Sensor, id_suffix="last_feeding_source"): @MULTI_MATCH(cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"}) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class AqaraPetFeederLastFeedingSize(Sensor, id_suffix="last_feeding_size"): """Sensor that displays the last feeding size of the pet feeder.""" @@ -962,6 +1003,7 @@ class AqaraPetFeederLastFeedingSize(Sensor, id_suffix="last_feeding_size"): @MULTI_MATCH(cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"}) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class AqaraPetFeederPortionsDispensed(Sensor, id_suffix="portions_dispensed"): """Sensor that displays the number of portions dispensed by the pet feeder.""" @@ -972,6 +1014,7 @@ class AqaraPetFeederPortionsDispensed(Sensor, id_suffix="portions_dispensed"): @MULTI_MATCH(cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"}) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class AqaraPetFeederWeightDispensed(Sensor, id_suffix="weight_dispensed"): """Sensor that displays the weight dispensed by the pet feeder.""" @@ -983,6 +1026,7 @@ class AqaraPetFeederWeightDispensed(Sensor, id_suffix="weight_dispensed"): @MULTI_MATCH(cluster_handler_names="opple_cluster", models={"lumi.sensor_smoke.acn03"}) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class AqaraSmokeDensityDbm(Sensor, id_suffix="smoke_density_dbm"): """Sensor that displays the smoke density of an Aqara smoke sensor in dB/m.""" diff --git a/pylint/plugins/hass_inheritance.py b/pylint/plugins/hass_inheritance.py new file mode 100644 index 000000000000..afbe3453c7ea --- /dev/null +++ b/pylint/plugins/hass_inheritance.py @@ -0,0 +1,66 @@ +"""Plugin to enforce type hints on specific functions.""" +from __future__ import annotations + +import re + +from astroid import nodes +from pylint.checkers import BaseChecker +from pylint.lint import PyLinter + +_MODULE_REGEX: re.Pattern[str] = re.compile(r"^homeassistant\.components\.\w+(\.\w+)?$") + + +def _get_module_platform(module_name: str) -> str | None: + """Return the platform for the module name.""" + if not (module_match := _MODULE_REGEX.match(module_name)): + # Ensure `homeassistant.components.` + # Or `homeassistant.components..` + return None + + platform = module_match.groups()[0] + return platform.lstrip(".") if platform else "__init__" + + +class HassInheritanceChecker(BaseChecker): # type: ignore[misc] + """Checker for invalid inheritance.""" + + name = "hass_inheritance" + priority = -1 + msgs = { + "W7411": ( + "Invalid inheritance: %s", + "hass-invalid-inheritance", + "Used when a class has inheritance has issues", + ), + } + options = () + + _module_name: str + _module_platform: str | None + + def visit_module(self, node: nodes.Module) -> None: + """Populate matchers for a Module node.""" + self._module_name = node.name + self._module_platform = _get_module_platform(node.name) + + def visit_classdef(self, node: nodes.ClassDef) -> None: + """Apply relevant type hint checks on a ClassDef node.""" + if self._module_platform != "sensor": + return + + ancestors = [a.name for a in node.ancestors()] + if ( + "RestoreEntity" in ancestors + and "SensorEntity" in ancestors + and "RestoreSensor" not in ancestors + ): + self.add_message( + "hass-invalid-inheritance", + node=node, + args="SensorEntity and RestoreEntity should not be combined, please use RestoreSensor", + ) + + +def register(linter: PyLinter) -> None: + """Register the checker.""" + linter.register_checker(HassInheritanceChecker(linter)) diff --git a/pyproject.toml b/pyproject.toml index 4a1a529d59ff..caf904d86d31 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -113,6 +113,7 @@ load-plugins = [ "pylint.extensions.code_style", "pylint.extensions.typing", "hass_enforce_type_hints", + "hass_inheritance", "hass_imports", "hass_logger", "pylint_per_file_ignores", From f355f0cc6d372c8116875edac7ee759f3c84ebfc Mon Sep 17 00:00:00 2001 From: Colin Robbins Date: Wed, 24 May 2023 12:44:12 +0100 Subject: [PATCH 509/822] Add UDP listener in Lightwave (#85385) --- .../components/lightwave/__init__.py | 19 +++++++++++++------ .../components/lightwave/manifest.json | 2 +- requirements_all.txt | 2 +- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/lightwave/__init__.py b/homeassistant/components/lightwave/__init__.py index e774a27999de..9feefd6e24dd 100644 --- a/homeassistant/components/lightwave/__init__.py +++ b/homeassistant/components/lightwave/__init__.py @@ -1,4 +1,6 @@ """Support for device connected via Lightwave WiFi-link hub.""" +import logging + from lightwave.lightwave import LWLink import voluptuous as vol @@ -20,12 +22,13 @@ CONF_PROXY_PORT = "proxy_port" CONF_TRV = "trv" CONF_TRVS = "trvs" DEFAULT_PROXY_PORT = 7878 -DEFAULT_PROXY_IP = "127.0.0.1" DOMAIN = "lightwave" LIGHTWAVE_LINK = f"{DOMAIN}_link" LIGHTWAVE_TRV_PROXY = f"{DOMAIN}_proxy" LIGHTWAVE_TRV_PROXY_PORT = f"{DOMAIN}_proxy_port" +_LOGGER = logging.getLogger(__name__) + CONFIG_SCHEMA = vol.Schema( { @@ -44,9 +47,7 @@ CONFIG_SCHEMA = vol.Schema( vol.Optional( CONF_PROXY_PORT, default=DEFAULT_PROXY_PORT ): cv.port, - vol.Optional( - CONF_PROXY_IP, default=DEFAULT_PROXY_IP - ): cv.string, + vol.Optional(CONF_PROXY_IP): cv.string, vol.Required(CONF_TRVS, default={}): { cv.string: vol.Schema( { @@ -84,9 +85,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if trv := config[DOMAIN][CONF_TRV]: trvs = trv[CONF_TRVS] - proxy_ip = trv[CONF_PROXY_IP] + proxy_ip = trv.get(CONF_PROXY_IP) proxy_port = trv[CONF_PROXY_PORT] - lwlink.set_trv_proxy(proxy_ip, proxy_port) + if proxy_ip is None: + await lwlink.LW_listen() + else: + lwlink.set_trv_proxy(proxy_ip, proxy_port) + _LOGGER.warning( + "Proxy no longer required, remove `proxy_ip` from config to use builtin listener" + ) for platform in PLATFORMS: hass.async_create_task( diff --git a/homeassistant/components/lightwave/manifest.json b/homeassistant/components/lightwave/manifest.json index 86c6a9263f3d..d242195a71c0 100644 --- a/homeassistant/components/lightwave/manifest.json +++ b/homeassistant/components/lightwave/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/lightwave", "iot_class": "assumed_state", "loggers": ["lightwave"], - "requirements": ["lightwave==0.20"] + "requirements": ["lightwave==0.24"] } diff --git a/requirements_all.txt b/requirements_all.txt index 80999fc96bb1..3c0b6c24162e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1073,7 +1073,7 @@ life360==5.5.0 lightify==1.0.7.3 # homeassistant.components.lightwave -lightwave==0.20 +lightwave==0.24 # homeassistant.components.limitlessled limitlessled==1.1.3 From ace45f31ff87de242adfacee8e8df3e97e95dde5 Mon Sep 17 00:00:00 2001 From: David Knowles Date: Wed, 24 May 2023 08:07:37 -0400 Subject: [PATCH 510/822] Add a DataUpdateCoordinator to Hydrawise (#93223) * Add a DataUpdateCoordinator to Hydrawise * Replace DATA_HYDRAWISE with DOMAIN * Replace persistent notification with a ConfigEntryNotReady exception * Changes requested during PR review * Add a type annotation to the `monitored_conditions` field. Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .../components/hydrawise/__init__.py | 50 ++++++++---------- .../components/hydrawise/binary_sensor.py | 41 +++++++++------ homeassistant/components/hydrawise/const.py | 1 - .../components/hydrawise/coordinator.py | 29 +++++++++++ homeassistant/components/hydrawise/entity.py | 36 ++++++------- homeassistant/components/hydrawise/sensor.py | 18 ++++--- homeassistant/components/hydrawise/switch.py | 52 ++++++++++++------- 7 files changed, 136 insertions(+), 91 deletions(-) create mode 100644 homeassistant/components/hydrawise/coordinator.py diff --git a/homeassistant/components/hydrawise/__init__.py b/homeassistant/components/hydrawise/__init__.py index ba561a14f82d..d5b69617ba9c 100644 --- a/homeassistant/components/hydrawise/__init__.py +++ b/homeassistant/components/hydrawise/__init__.py @@ -1,5 +1,6 @@ """Support for Hydrawise cloud.""" + from hydrawiser.core import Hydrawiser from requests.exceptions import ConnectTimeout, HTTPError import voluptuous as vol @@ -8,19 +9,10 @@ from homeassistant.components import persistent_notification from homeassistant.const import CONF_ACCESS_TOKEN, CONF_SCAN_INTERVAL from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.dispatcher import dispatcher_send -from homeassistant.helpers.event import track_time_interval from homeassistant.helpers.typing import ConfigType -from .const import ( - DATA_HYDRAWISE, - DOMAIN, - LOGGER, - NOTIFICATION_ID, - NOTIFICATION_TITLE, - SCAN_INTERVAL, - SIGNAL_UPDATE_HYDRAWISE, -) +from .const import DOMAIN, LOGGER, NOTIFICATION_ID, NOTIFICATION_TITLE, SCAN_INTERVAL +from .coordinator import HydrawiseDataUpdateCoordinator CONFIG_SCHEMA = vol.Schema( { @@ -35,37 +27,41 @@ CONFIG_SCHEMA = vol.Schema( ) -def setup(hass: HomeAssistant, config: ConfigType) -> bool: +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) try: - hydrawise = Hydrawiser(user_token=access_token) - hass.data[DATA_HYDRAWISE] = HydrawiseHub(hydrawise) + hydrawise = await hass.async_add_executor_job(Hydrawiser, access_token) except (ConnectTimeout, HTTPError) as ex: LOGGER.error("Unable to connect to Hydrawise cloud service: %s", str(ex)) - persistent_notification.create( - hass, - f"Error: {ex}
You will need to restart hass after fixing.", - title=NOTIFICATION_TITLE, - notification_id=NOTIFICATION_ID, - ) + _show_failure_notification(hass, str(ex)) return False - def hub_refresh(event_time): - """Call Hydrawise hub to refresh information.""" - LOGGER.debug("Updating Hydrawise Hub component") - hass.data[DATA_HYDRAWISE].data.update_controller_info() - dispatcher_send(hass, SIGNAL_UPDATE_HYDRAWISE) + if not hydrawise.current_controller: + LOGGER.error("Failed to fetch Hydrawise data") + _show_failure_notification(hass, "Failed to fetch Hydrawise data.") + return False - # Call the Hydrawise API to refresh updates - track_time_interval(hass, hub_refresh, scan_interval) + hass.data[DOMAIN] = HydrawiseDataUpdateCoordinator(hass, hydrawise, scan_interval) + + # NOTE: We don't need to call async_config_entry_first_refresh() because + # data is fetched when the Hydrawiser object is instantiated. return True +def _show_failure_notification(hass: HomeAssistant, error: str): + persistent_notification.create( + hass, + f"Error: {error}
You will need to restart hass after fixing.", + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID, + ) + + class HydrawiseHub: """Representation of a base Hydrawise device.""" diff --git a/homeassistant/components/hydrawise/binary_sensor.py b/homeassistant/components/hydrawise/binary_sensor.py index 93594d714363..2986bbb170ea 100644 --- a/homeassistant/components/hydrawise/binary_sensor.py +++ b/homeassistant/components/hydrawise/binary_sensor.py @@ -1,6 +1,7 @@ """Support for Hydrawise sprinkler binary sensors.""" from __future__ import annotations +from hydrawiser.core import Hydrawiser import voluptuous as vol from homeassistant.components.binary_sensor import ( @@ -10,12 +11,13 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.const import CONF_MONITORED_CONDITIONS -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import DATA_HYDRAWISE, LOGGER +from .const import DOMAIN, LOGGER +from .coordinator import HydrawiseDataUpdateCoordinator from .entity import HydrawiseEntity BINARY_SENSOR_STATUS = BinarySensorEntityDescription( @@ -52,24 +54,30 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up a sensor for a Hydrawise device.""" - hydrawise = hass.data[DATA_HYDRAWISE].data + coordinator: HydrawiseDataUpdateCoordinator = hass.data[DOMAIN] + hydrawise: Hydrawiser = coordinator.api monitored_conditions = config[CONF_MONITORED_CONDITIONS] entities = [] if BINARY_SENSOR_STATUS.key in monitored_conditions: entities.append( - HydrawiseBinarySensor(hydrawise.current_controller, BINARY_SENSOR_STATUS) + HydrawiseBinarySensor( + data=hydrawise.current_controller, + coordinator=coordinator, + description=BINARY_SENSOR_STATUS, + ) ) # create a sensor for each zone - entities.extend( - [ - HydrawiseBinarySensor(zone, description) - for zone in hydrawise.relays - for description in BINARY_SENSOR_TYPES - if description.key in monitored_conditions - ] - ) + 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) @@ -77,12 +85,13 @@ def setup_platform( class HydrawiseBinarySensor(HydrawiseEntity, BinarySensorEntity): """A sensor implementation for Hydrawise device.""" - def update(self) -> None: + @callback + def _handle_coordinator_update(self) -> None: """Get the latest data and updates the state.""" LOGGER.debug("Updating Hydrawise binary sensor: %s", self.name) - mydata = self.hass.data[DATA_HYDRAWISE].data if self.entity_description.key == "status": - self._attr_is_on = mydata.status == "All good!" + self._attr_is_on = self.coordinator.api.status == "All good!" elif self.entity_description.key == "is_watering": - relay_data = mydata.relays[self.data["relay"] - 1] + relay_data = self.coordinator.api.relays[self.data["relay"] - 1] self._attr_is_on = relay_data["timestr"] == "Now" + super()._handle_coordinator_update() diff --git a/homeassistant/components/hydrawise/const.py b/homeassistant/components/hydrawise/const.py index 5a046530f014..515fdaec2b15 100644 --- a/homeassistant/components/hydrawise/const.py +++ b/homeassistant/components/hydrawise/const.py @@ -11,7 +11,6 @@ CONF_WATERING_TIME = "watering_minutes" NOTIFICATION_ID = "hydrawise_notification" NOTIFICATION_TITLE = "Hydrawise Setup" -DATA_HYDRAWISE = "hydrawise" DOMAIN = "hydrawise" DEFAULT_WATERING_TIME = 15 diff --git a/homeassistant/components/hydrawise/coordinator.py b/homeassistant/components/hydrawise/coordinator.py new file mode 100644 index 000000000000..ea2e2dd2c4c0 --- /dev/null +++ b/homeassistant/components/hydrawise/coordinator.py @@ -0,0 +1,29 @@ +"""DataUpdateCoordinator for the Hydrawise integration.""" + +from __future__ import annotations + +from datetime import timedelta + +from hydrawiser.core import Hydrawiser + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, LOGGER + + +class HydrawiseDataUpdateCoordinator(DataUpdateCoordinator[None]): + """The Hydrawise Data Update Coordinator.""" + + def __init__( + self, hass: HomeAssistant, api: Hydrawiser, scan_interval: timedelta + ) -> None: + """Initialize HydrawiseDataUpdateCoordinator.""" + super().__init__(hass, LOGGER, name=DOMAIN, update_interval=scan_interval) + self.api = api + + async def _async_update_data(self) -> None: + """Fetch the latest data from Hydrawise.""" + result = await self.hass.async_add_executor_job(self.api.update_controller_info) + if not result: + raise UpdateFailed("Failed to refresh Hydrawise data") diff --git a/homeassistant/components/hydrawise/entity.py b/homeassistant/components/hydrawise/entity.py index 5c54c1ee5808..405e5bc3fa33 100644 --- a/homeassistant/components/hydrawise/entity.py +++ b/homeassistant/components/hydrawise/entity.py @@ -1,36 +1,32 @@ """Base classes for Hydrawise entities.""" -from homeassistant.core import callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import Entity, EntityDescription +from typing import Any -from .const import SIGNAL_UPDATE_HYDRAWISE +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) -class HydrawiseEntity(Entity): +class HydrawiseEntity(CoordinatorEntity): """Entity class for Hydrawise devices.""" _attr_attribution = "Data provided by hydrawise.com" - def __init__(self, data, description: EntityDescription) -> None: + def __init__( + self, + *, + data: dict[str, Any], + coordinator: DataUpdateCoordinator, + description: EntityDescription, + ) -> None: """Initialize the Hydrawise entity.""" - self.entity_description = description + super().__init__(coordinator=coordinator) self.data = data + self.entity_description = description self._attr_name = f"{self.data['name']} {description.name}" - async def async_added_to_hass(self): - """Register callbacks.""" - self.async_on_remove( - async_dispatcher_connect( - self.hass, SIGNAL_UPDATE_HYDRAWISE, self._update_callback - ) - ) - - @callback - def _update_callback(self): - """Call update method.""" - self.async_schedule_update_ha_state(True) - @property def extra_state_attributes(self): """Return the state attributes.""" diff --git a/homeassistant/components/hydrawise/sensor.py b/homeassistant/components/hydrawise/sensor.py index 2cec1309ec95..f47f404cfa0a 100644 --- a/homeassistant/components/hydrawise/sensor.py +++ b/homeassistant/components/hydrawise/sensor.py @@ -1,6 +1,7 @@ """Support for Hydrawise sprinkler sensors.""" from __future__ import annotations +from hydrawiser.core import Hydrawiser import voluptuous as vol from homeassistant.components.sensor import ( @@ -10,13 +11,14 @@ from homeassistant.components.sensor import ( SensorEntityDescription, ) from homeassistant.const import CONF_MONITORED_CONDITIONS, UnitOfTime -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback 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 -from .const import DATA_HYDRAWISE, LOGGER +from .const import DOMAIN, LOGGER +from .coordinator import HydrawiseDataUpdateCoordinator from .entity import HydrawiseEntity SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( @@ -54,11 +56,12 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up a sensor for a Hydrawise device.""" - hydrawise = hass.data[DATA_HYDRAWISE].data + coordinator: HydrawiseDataUpdateCoordinator = hass.data[DOMAIN] + hydrawise: Hydrawiser = coordinator.api monitored_conditions = config[CONF_MONITORED_CONDITIONS] entities = [ - HydrawiseSensor(zone, description) + HydrawiseSensor(data=zone, coordinator=coordinator, description=description) for zone in hydrawise.relays for description in SENSOR_TYPES if description.key in monitored_conditions @@ -70,11 +73,11 @@ def setup_platform( class HydrawiseSensor(HydrawiseEntity, SensorEntity): """A sensor implementation for Hydrawise device.""" - def update(self) -> None: + @callback + def _handle_coordinator_update(self) -> None: """Get the latest data and updates the states.""" - mydata = self.hass.data[DATA_HYDRAWISE].data LOGGER.debug("Updating Hydrawise sensor: %s", self.name) - relay_data = mydata.relays[self.data["relay"] - 1] + relay_data = self.coordinator.api.relays[self.data["relay"] - 1] if self.entity_description.key == "watering_time": if relay_data["timestr"] == "Now": self._attr_native_value = int(relay_data["run"] / 60) @@ -86,3 +89,4 @@ class HydrawiseSensor(HydrawiseEntity, SensorEntity): self._attr_native_value = dt.utc_from_timestamp( dt.as_timestamp(dt.now()) + next_cycle ) + super()._handle_coordinator_update() diff --git a/homeassistant/components/hydrawise/switch.py b/homeassistant/components/hydrawise/switch.py index ac9b0d27025c..71c4f8df79ea 100644 --- a/homeassistant/components/hydrawise/switch.py +++ b/homeassistant/components/hydrawise/switch.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import Any +from hydrawiser.core import Hydrawiser import voluptuous as vol from homeassistant.components.switch import ( @@ -12,18 +13,20 @@ from homeassistant.components.switch import ( SwitchEntityDescription, ) from homeassistant.const import CONF_MONITORED_CONDITIONS -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback 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 DataUpdateCoordinator from .const import ( ALLOWED_WATERING_TIME, CONF_WATERING_TIME, - DATA_HYDRAWISE, DEFAULT_WATERING_TIME, + DOMAIN, LOGGER, ) +from .coordinator import HydrawiseDataUpdateCoordinator from .entity import HydrawiseEntity SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( @@ -60,12 +63,18 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up a sensor for a Hydrawise device.""" - hydrawise = hass.data[DATA_HYDRAWISE].data - monitored_conditions = config[CONF_MONITORED_CONDITIONS] - default_watering_timer = config[CONF_WATERING_TIME] + coordinator: HydrawiseDataUpdateCoordinator = hass.data[DOMAIN] + hydrawise: Hydrawiser = coordinator.api + monitored_conditions: list[str] = config[CONF_MONITORED_CONDITIONS] + default_watering_timer: int = config[CONF_WATERING_TIME] entities = [ - HydrawiseSwitch(zone, description, default_watering_timer) + HydrawiseSwitch( + data=zone, + coordinator=coordinator, + description=description, + default_watering_timer=default_watering_timer, + ) for zone in hydrawise.relays for description in SWITCH_TYPES if description.key in monitored_conditions @@ -78,38 +87,41 @@ class HydrawiseSwitch(HydrawiseEntity, SwitchEntity): """A switch implementation for Hydrawise device.""" def __init__( - self, data, description: SwitchEntityDescription, default_watering_timer + self, + *, + data: dict[str, Any], + coordinator: DataUpdateCoordinator, + description: SwitchEntityDescription, + default_watering_timer: int, ) -> None: """Initialize a switch for Hydrawise device.""" - super().__init__(data, description) + super().__init__(data=data, coordinator=coordinator, description=description) self._default_watering_timer = default_watering_timer def turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" relay_data = self.data["relay"] - 1 if self.entity_description.key == "manual_watering": - self.hass.data[DATA_HYDRAWISE].data.run_zone( - self._default_watering_timer, relay_data - ) + self.coordinator.api.run_zone(self._default_watering_timer, relay_data) elif self.entity_description.key == "auto_watering": - self.hass.data[DATA_HYDRAWISE].data.suspend_zone(0, relay_data) + self.coordinator.api.suspend_zone(0, relay_data) def turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" relay_data = self.data["relay"] - 1 if self.entity_description.key == "manual_watering": - self.hass.data[DATA_HYDRAWISE].data.run_zone(0, relay_data) + self.coordinator.api.run_zone(0, relay_data) elif self.entity_description.key == "auto_watering": - self.hass.data[DATA_HYDRAWISE].data.suspend_zone(365, relay_data) + self.coordinator.api.suspend_zone(365, relay_data) - def update(self) -> None: + @callback + def _handle_coordinator_update(self) -> None: """Update device state.""" relay_data = self.data["relay"] - 1 - mydata = self.hass.data[DATA_HYDRAWISE].data LOGGER.debug("Updating Hydrawise switch: %s", self.name) + timestr = self.coordinator.api.relays[relay_data]["timestr"] if self.entity_description.key == "manual_watering": - self._attr_is_on = mydata.relays[relay_data]["timestr"] == "Now" + self._attr_is_on = timestr == "Now" elif self.entity_description.key == "auto_watering": - self._attr_is_on = (mydata.relays[relay_data]["timestr"] != "") and ( - mydata.relays[relay_data]["timestr"] != "Now" - ) + self._attr_is_on = timestr not in {"", "Now"} + super()._handle_coordinator_update() From 7e165021a3f6cf16a123ce361391f4412ad47310 Mon Sep 17 00:00:00 2001 From: Jeff Grieger Date: Wed, 24 May 2023 08:22:56 -0400 Subject: [PATCH 511/822] Fix directv attribute media_position_updated_at (#92383) * Fix directv attribute media_position_updated_at Fix to update media_position_updated_at attribute when media_position attribute is updated. * Do not update media_position_updated_at when paused --- homeassistant/components/directv/media_player.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/directv/media_player.py b/homeassistant/components/directv/media_player.py index 21b25962fce1..8c1570db1596 100644 --- a/homeassistant/components/directv/media_player.py +++ b/homeassistant/components/directv/media_player.py @@ -112,7 +112,8 @@ class DIRECTVMediaPlayer(DIRECTVEntity, MediaPlayerEntity): self._paused = self._last_position == self._program.position self._is_recorded = self._program.recorded self._last_position = self._program.position - self._last_update = state.at + if not self._paused: + self._last_update = dt_util.utcnow() self._attr_assumed_state = self._is_recorded @property From ef24e508f81f35e3b3a18a81e0242830d8b96052 Mon Sep 17 00:00:00 2001 From: ad0p <96997734+ad0p@users.noreply.github.com> Date: Wed, 24 May 2023 14:43:02 +0200 Subject: [PATCH 512/822] Bump pydeconz to v112 (#91924) * Bump pydeconz to v112 * PR review - add suggested_display_precision * Test - fix * Bump pydeconz to v112 * PR review - add suggested_display_precision * Fix tests --------- Co-authored-by: Robert Svensson --- homeassistant/components/deconz/manifest.json | 2 +- homeassistant/components/deconz/sensor.py | 2 ++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/deconz/test_sensor.py | 10 ++++++++-- 5 files changed, 13 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/deconz/manifest.json b/homeassistant/components/deconz/manifest.json index 61794e7c70a2..8139d77df85d 100644 --- a/homeassistant/components/deconz/manifest.json +++ b/homeassistant/components/deconz/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["pydeconz"], "quality_scale": "platinum", - "requirements": ["pydeconz==111"], + "requirements": ["pydeconz==112"], "ssdp": [ { "manufacturer": "Royal Philips Electronics", diff --git a/homeassistant/components/deconz/sensor.py b/homeassistant/components/deconz/sensor.py index 136f582f5c7f..e5d5de410081 100644 --- a/homeassistant/components/deconz/sensor.py +++ b/homeassistant/components/deconz/sensor.py @@ -194,6 +194,7 @@ ENTITY_DESCRIPTIONS: tuple[DeconzSensorDescription, ...] = ( device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=1, ), DeconzSensorDescription[LightLevel]( key="light_level", @@ -234,6 +235,7 @@ ENTITY_DESCRIPTIONS: tuple[DeconzSensorDescription, ...] = ( device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=1, ), DeconzSensorDescription[Time]( key="last_set", diff --git a/requirements_all.txt b/requirements_all.txt index 3c0b6c24162e..6173d502c817 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1581,7 +1581,7 @@ pydaikin==2.9.0 pydanfossair==0.1.0 # homeassistant.components.deconz -pydeconz==111 +pydeconz==112 # homeassistant.components.delijn pydelijn==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4bc1e13519a2..8af9d775bebe 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1166,7 +1166,7 @@ pycsspeechtts==1.0.8 pydaikin==2.9.0 # homeassistant.components.deconz -pydeconz==111 +pydeconz==112 # homeassistant.components.dexcom pydexcom==0.2.3 diff --git a/tests/components/deconz/test_sensor.py b/tests/components/deconz/test_sensor.py index eb27d9f68d4b..9c5dc937448e 100644 --- a/tests/components/deconz/test_sensor.py +++ b/tests/components/deconz/test_sensor.py @@ -423,7 +423,7 @@ TEST_DATA = [ "entity_id": "sensor.mi_temperature_1", "unique_id": "00:15:8d:00:02:45:dc:53-01-0405-humidity", "old_unique_id": "00:15:8d:00:02:45:dc:53-01-0405", - "state": "35.5", + "state": "35.55", "entity_category": None, "device_class": SensorDeviceClass.HUMIDITY, "state_class": SensorStateClass.MEASUREMENT, @@ -434,6 +434,9 @@ TEST_DATA = [ "device_class": "humidity", "friendly_name": "Mi temperature 1", }, + "options": { + "suggested_display_precision": 1, + }, "websocket_event": {"state": {"humidity": 1000}}, "next_state": "10.0", }, @@ -601,7 +604,7 @@ TEST_DATA = [ "entity_id": "sensor.mi_temperature_1", "unique_id": "00:15:8d:00:02:45:dc:53-01-0402-temperature", "old_unique_id": "00:15:8d:00:02:45:dc:53-01-0402", - "state": "21.8", + "state": "21.82", "entity_category": None, "device_class": SensorDeviceClass.TEMPERATURE, "state_class": SensorStateClass.MEASUREMENT, @@ -612,6 +615,9 @@ TEST_DATA = [ "device_class": "temperature", "friendly_name": "Mi temperature 1", }, + "options": { + "suggested_display_precision": 1, + }, "websocket_event": {"state": {"temperature": 1800}}, "next_state": "18.0", }, From 89f1677307bff32cf214d187282830f674854197 Mon Sep 17 00:00:00 2001 From: Aaron Godfrey Date: Wed, 24 May 2023 05:46:13 -0700 Subject: [PATCH 513/822] Fix todoist state updates (#91915) * Update event when coordinator updates data. * Move function to make diff easier to view * Actually make it easier to view * Move all tasks calculation to extra_state_attributes. --- homeassistant/components/todoist/calendar.py | 20 +++++++++++--------- tests/components/todoist/test_calendar.py | 3 --- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/todoist/calendar.py b/homeassistant/components/todoist/calendar.py index 98910d7af38e..1c356a857a68 100644 --- a/homeassistant/components/todoist/calendar.py +++ b/homeassistant/components/todoist/calendar.py @@ -18,7 +18,7 @@ from homeassistant.components.calendar import ( CalendarEvent, ) from homeassistant.const import CONF_ID, CONF_NAME, CONF_TOKEN, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import Event, HomeAssistant, ServiceCall +from homeassistant.core import Event, HomeAssistant, ServiceCall, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -175,7 +175,7 @@ async def async_setup_platform( ) ) - async_add_entities(project_devices) + async_add_entities(project_devices, update_before_add=True) session = async_get_clientsession(hass) @@ -304,6 +304,12 @@ class TodoistProjectEntity(CoordinatorEntity[TodoistCoordinator], CalendarEntity str(data[CONF_ID]) if data.get(CONF_ID) is not None else None ) + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self.data.update() + super()._handle_coordinator_update() + @property def event(self) -> CalendarEvent | None: """Return the next upcoming event.""" @@ -317,11 +323,7 @@ class TodoistProjectEntity(CoordinatorEntity[TodoistCoordinator], CalendarEntity async def async_update(self) -> None: """Update all Todoist Calendars.""" await super().async_update() - await self.data.async_update() - # Set Todoist-specific data that can't easily be grabbed - self._cal_data["all_tasks"] = [ - task[SUMMARY] for task in self.data.all_project_tasks - ] + self.data.update() async def async_get_events( self, @@ -342,7 +344,7 @@ class TodoistProjectEntity(CoordinatorEntity[TodoistCoordinator], CalendarEntity return { DUE_TODAY: self.data.event[DUE_TODAY], OVERDUE: self.data.event[OVERDUE], - ALL_TASKS: self._cal_data[ALL_TASKS], + ALL_TASKS: [task[SUMMARY] for task in self.data.all_project_tasks], PRIORITY: self.data.event[PRIORITY], LABELS: self.data.event[LABELS], } @@ -610,7 +612,7 @@ class TodoistProjectData: events.append(event) return events - async def async_update(self) -> None: + def update(self) -> None: """Get the latest data.""" tasks = self._coordinator.data if self._id is None: diff --git a/tests/components/todoist/test_calendar.py b/tests/components/todoist/test_calendar.py index 75c07be7ec38..5a6a05623c74 100644 --- a/tests/components/todoist/test_calendar.py +++ b/tests/components/todoist/test_calendar.py @@ -216,9 +216,6 @@ async def test_calendar_custom_project_unique_id( entity = entity_registry.async_get("calendar.all_projects") assert entity is None - state = hass.states.get("calendar.all_projects") - assert state.state == "off" - @pytest.mark.parametrize( ("due", "start", "end", "expected_response"), From 11accd8ba763da86c7c407549c31e122e3d8d171 Mon Sep 17 00:00:00 2001 From: rappenze Date: Wed, 24 May 2023 14:54:10 +0200 Subject: [PATCH 514/822] Set available state in fibaro integration (#91893) --- homeassistant/components/fibaro/__init__.py | 5 +++++ homeassistant/components/fibaro/binary_sensor.py | 1 + homeassistant/components/fibaro/light.py | 1 + homeassistant/components/fibaro/lock.py | 1 + homeassistant/components/fibaro/sensor.py | 2 ++ homeassistant/components/fibaro/switch.py | 1 + 6 files changed, 11 insertions(+) diff --git a/homeassistant/components/fibaro/__init__.py b/homeassistant/components/fibaro/__init__.py index 3313e51ce759..18b47649a186 100644 --- a/homeassistant/components/fibaro/__init__.py +++ b/homeassistant/components/fibaro/__init__.py @@ -527,6 +527,11 @@ class FibaroDevice(Entity): return attr + def update(self) -> None: + """Update the available state of the entity.""" + if isinstance(self.fibaro_device, DeviceModel) and self.fibaro_device.has_dead: + self._attr_available = not self.fibaro_device.dead + class FibaroConnectFailed(HomeAssistantError): """Error to indicate we cannot connect to fibaro home center.""" diff --git a/homeassistant/components/fibaro/binary_sensor.py b/homeassistant/components/fibaro/binary_sensor.py index 14f0a6a162c3..57b3bc99b4f4 100644 --- a/homeassistant/components/fibaro/binary_sensor.py +++ b/homeassistant/components/fibaro/binary_sensor.py @@ -82,6 +82,7 @@ class FibaroBinarySensor(FibaroDevice, BinarySensorEntity): def update(self) -> None: """Get the latest data and update the state.""" + super().update() if self._fibaro_sensor_type == "com.fibaro.accelerometer": # Accelerator sensors have values for the three axis x, y and z moving_values = self._get_moving_values() diff --git a/homeassistant/components/fibaro/light.py b/homeassistant/components/fibaro/light.py index 577c66125529..6a918f64f86b 100644 --- a/homeassistant/components/fibaro/light.py +++ b/homeassistant/components/fibaro/light.py @@ -176,6 +176,7 @@ class FibaroLight(FibaroDevice, LightEntity): def _update(self): """Really update the state.""" + super().update() # Brightness handling if brightness_supported(self.supported_color_modes): self._attr_brightness = scaleto255(self.fibaro_device.value.int_value()) diff --git a/homeassistant/components/fibaro/lock.py b/homeassistant/components/fibaro/lock.py index 0fa1337e3e35..503407bc28f2 100644 --- a/homeassistant/components/fibaro/lock.py +++ b/homeassistant/components/fibaro/lock.py @@ -52,4 +52,5 @@ class FibaroLock(FibaroDevice, LockEntity): def update(self) -> None: """Update device state.""" + super().update() self._attr_is_locked = self.current_binary_state diff --git a/homeassistant/components/fibaro/sensor.py b/homeassistant/components/fibaro/sensor.py index 6bb8291bbbb0..c41c4afe312c 100644 --- a/homeassistant/components/fibaro/sensor.py +++ b/homeassistant/components/fibaro/sensor.py @@ -146,6 +146,7 @@ class FibaroSensor(FibaroDevice, SensorEntity): def update(self) -> None: """Update the state.""" + super().update() with suppress(TypeError): self._attr_native_value = self.fibaro_device.value.float_value() @@ -170,6 +171,7 @@ class FibaroAdditionalSensor(FibaroDevice, SensorEntity): def update(self) -> None: """Update the state.""" + super().update() with suppress(KeyError, ValueError): self._attr_native_value = convert( self.fibaro_device.properties[self.entity_description.key], diff --git a/homeassistant/components/fibaro/switch.py b/homeassistant/components/fibaro/switch.py index d5c6eebeee50..6ca770ab2d1c 100644 --- a/homeassistant/components/fibaro/switch.py +++ b/homeassistant/components/fibaro/switch.py @@ -52,4 +52,5 @@ class FibaroSwitch(FibaroDevice, SwitchEntity): def update(self) -> None: """Update device state.""" + super().update() self._attr_is_on = self.current_binary_state From 83f206a6fe87fd7f07a947bf060906dd8aa868cd Mon Sep 17 00:00:00 2001 From: rappenze Date: Wed, 24 May 2023 14:57:35 +0200 Subject: [PATCH 515/822] Code cleanup: Separate fibaro scene logic (#91895) * Code cleanup fibaro scene * Remove duplicated code * Fixes --- homeassistant/components/fibaro/__init__.py | 35 +++++-------- homeassistant/components/fibaro/scene.py | 25 +++++++--- tests/components/fibaro/conftest.py | 53 +++++++++++++++++++- tests/components/fibaro/test_scene.py | 54 +++++++++++++++++++++ 4 files changed, 138 insertions(+), 29 deletions(-) create mode 100644 tests/components/fibaro/test_scene.py diff --git a/homeassistant/components/fibaro/__init__.py b/homeassistant/components/fibaro/__init__.py index 18b47649a186..dc7be9f1e69d 100644 --- a/homeassistant/components/fibaro/__init__.py +++ b/homeassistant/components/fibaro/__init__.py @@ -8,7 +8,6 @@ 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 @@ -278,24 +277,18 @@ class FibaroController: return self._device_infos[device.parent_fibaro_id] return DeviceInfo(identifiers={(DOMAIN, self.hub_serial)}) + def get_room_name(self, room_id: int) -> str | None: + """Get the room name by room id.""" + assert self._room_map + 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 - if device.room_id == 0: - room_name = "Unknown" - else: - room_name = self._room_map[device.room_id].name - device.room_name = room_name - device.friendly_name = f"{room_name} {device.name}" - device.ha_id = ( - f"scene_{slugify(room_name)}_{slugify(device.name)}_{device.fibaro_id}" - ) - device.unique_id_str = ( - f"{slugify(self.hub_serial)}.scene.{device.fibaro_id}" - ) self.fibaro_devices[Platform.SCENE].append(device) - _LOGGER.debug("%s scene -> %s", device.ha_id, device) + _LOGGER.debug("Scene -> %s", device) def _read_devices(self): """Read and process the device list.""" @@ -425,7 +418,7 @@ class FibaroDevice(Entity): _attr_should_poll = False - def __init__(self, fibaro_device: DeviceModel | SceneModel) -> None: + def __init__(self, fibaro_device: DeviceModel) -> None: """Initialize the device.""" self.fibaro_device = fibaro_device self.controller = fibaro_device.fibaro_controller @@ -433,8 +426,7 @@ class FibaroDevice(Entity): self._attr_name = fibaro_device.friendly_name self._attr_unique_id = fibaro_device.unique_id_str - if isinstance(fibaro_device, DeviceModel): - self._attr_device_info = self.controller.get_device_info(fibaro_device) + self._attr_device_info = self.controller.get_device_info(fibaro_device) # propagate hidden attribute set in fibaro home center to HA if not fibaro_device.visible: self._attr_entity_registry_visible_default = False @@ -519,11 +511,10 @@ class FibaroDevice(Entity): """Return the state attributes of the device.""" attr = {"fibaro_id": self.fibaro_device.fibaro_id} - if isinstance(self.fibaro_device, DeviceModel): - if self.fibaro_device.has_battery_level: - attr[ATTR_BATTERY_LEVEL] = self.fibaro_device.battery_level - if self.fibaro_device.has_armed: - attr[ATTR_ARMED] = self.fibaro_device.armed + if self.fibaro_device.has_battery_level: + attr[ATTR_BATTERY_LEVEL] = self.fibaro_device.battery_level + if self.fibaro_device.has_armed: + attr[ATTR_ARMED] = self.fibaro_device.armed return attr diff --git a/homeassistant/components/fibaro/scene.py b/homeassistant/components/fibaro/scene.py index 0023b8e3fba7..43baa0e4efd4 100644 --- a/homeassistant/components/fibaro/scene.py +++ b/homeassistant/components/fibaro/scene.py @@ -11,8 +11,9 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import slugify -from . import FIBARO_DEVICES, FibaroDevice +from . import FIBARO_DEVICES, FibaroController from .const import DOMAIN @@ -33,18 +34,30 @@ async def async_setup_entry( ) -class FibaroScene(FibaroDevice, Scene): +class FibaroScene(Scene): """Representation of a Fibaro scene entity.""" - def __init__(self, fibaro_device: SceneModel) -> None: + def __init__(self, fibaro_scene: SceneModel) -> None: """Initialize the Fibaro scene.""" - super().__init__(fibaro_device) + 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" + + self._attr_name = f"{room_name} {fibaro_scene.name}" + self._attr_unique_id = ( + f"{slugify(controller.hub_serial)}.scene.{fibaro_scene.fibaro_id}" + ) + self._attr_extra_state_attributes = {"fibaro_id": fibaro_scene.fibaro_id} + # propagate hidden attribute set in fibaro home center to HA + self._attr_entity_registry_visible_default = fibaro_scene.visible # All scenes are shown on hub device self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self.controller.hub_serial)} + identifiers={(DOMAIN, controller.hub_serial)} ) def activate(self, **kwargs: Any) -> None: """Activate the scene.""" - self.fibaro_device.start() + self._fibaro_scene.start() diff --git a/tests/components/fibaro/conftest.py b/tests/components/fibaro/conftest.py index 9f1c87c1d98a..056b23e1cf4c 100644 --- a/tests/components/fibaro/conftest.py +++ b/tests/components/fibaro/conftest.py @@ -1,9 +1,15 @@ """Test helpers.""" from collections.abc import Generator -from unittest.mock import AsyncMock, patch +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.config_entries import SOURCE_USER, ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock, None, None]: @@ -12,3 +18,48 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: "homeassistant.components.fibaro.async_setup_entry", return_value=True ) as mock_setup_entry: yield mock_setup_entry + + +@pytest.fixture(name="fibaro_scene") +def mock_scene() -> SceneModel: + """Fixture for an individual scene.""" + scene = Mock(SceneModel) + scene.fibaro_id = 1 + scene.name = "Test scene" + scene.room_id = 1 + scene.visible = True + return scene + + +async def setup_platform( + hass: HomeAssistant, + platform: Platform, + room_name: str | None, + scenes: list[SceneModel], +) -> ConfigEntry: + """Set up the fibaro platform and prerequisites.""" + hass.config.components.add(DOMAIN) + config_entry = ConfigEntry( + 1, + DOMAIN, + "Test", + {}, + SOURCE_USER, + ) + + controller_mock = Mock() + controller_mock.hub_serial = "HC2-111111" + controller_mock.get_room_name.return_value = room_name + + 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}, + } + } + await hass.config_entries.async_forward_entry_setup(config_entry, platform) + await hass.async_block_till_done() + return config_entry diff --git a/tests/components/fibaro/test_scene.py b/tests/components/fibaro/test_scene.py new file mode 100644 index 000000000000..09e0543976fd --- /dev/null +++ b/tests/components/fibaro/test_scene.py @@ -0,0 +1,54 @@ +"""Test the Fibaro scene platform.""" + +from pyfibaro.fibaro_scene import SceneModel + +from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import setup_platform + + +async def test_entity_attributes(hass: HomeAssistant, fibaro_scene: SceneModel) -> None: + """Test that the attributes of the entity are correct.""" + # Arrange + entity_registry = er.async_get(hass) + # Act + await setup_platform(hass, Platform.SCENE, "Room 1", [fibaro_scene]) + # Assert + entry = entity_registry.async_get("scene.room_1_test_scene") + + assert entry + assert entry.unique_id == "hc2_111111.scene.1" + assert entry.original_name == "Room 1 Test scene" + + +async def test_entity_attributes_without_room( + hass: HomeAssistant, fibaro_scene: SceneModel +) -> None: + """Test that the attributes of the entity are correct.""" + # Arrange + entity_registry = er.async_get(hass) + # Act + await setup_platform(hass, Platform.SCENE, None, [fibaro_scene]) + # Assert + entry = entity_registry.async_get("scene.unknown_test_scene") + + assert entry + assert entry.unique_id == "hc2_111111.scene.1" + + +async def test_activate_scene(hass: HomeAssistant, fibaro_scene: SceneModel) -> None: + """Test activate scene is called.""" + # Arrange + await setup_platform(hass, Platform.SCENE, "Room 1", [fibaro_scene]) + # Act + await hass.services.async_call( + SCENE_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "scene.room_1_test_scene"}, + blocking=True, + ) + # Assert + assert fibaro_scene.start.call_count == 1 From 3e93dd6a01ab2b73b96a6b5478fbe7a0379250c4 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Wed, 24 May 2023 09:00:37 -0400 Subject: [PATCH 516/822] Update name for Notification CC sensors and buttons (#93019) * Update name for Notification CC sensors and buttons * Add comment with reference to names --- homeassistant/components/zwave_js/button.py | 4 +++- homeassistant/components/zwave_js/entity.py | 7 +++++-- homeassistant/components/zwave_js/sensor.py | 22 +++++++++++++++++++++ tests/components/zwave_js/common.py | 2 +- tests/components/zwave_js/test_button.py | 9 ++++++--- tests/components/zwave_js/test_init.py | 6 ++++-- 6 files changed, 41 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/zwave_js/button.py b/homeassistant/components/zwave_js/button.py index e743284abddf..ae6cca03c3d3 100644 --- a/homeassistant/components/zwave_js/button.py +++ b/homeassistant/components/zwave_js/button.py @@ -122,7 +122,9 @@ class ZWaveNotificationIdleButton(ZWaveBaseEntity, ButtonEntity): """Initialize a ZWaveNotificationIdleButton entity.""" super().__init__(config_entry, driver, info) self._attr_name = self.generate_name( - include_value_name=True, name_prefix="Idle" + alternate_value_name=self.info.primary_value.property_name, + additional_info=[self.info.primary_value.property_key_name], + name_prefix="Idle", ) self._attr_unique_id = f"{self._attr_unique_id}.notification_idle" diff --git a/homeassistant/components/zwave_js/entity.py b/homeassistant/components/zwave_js/entity.py index 1f43f9f5bdb4..379fe4a18257 100644 --- a/homeassistant/components/zwave_js/entity.py +++ b/homeassistant/components/zwave_js/entity.py @@ -126,7 +126,7 @@ class ZWaveBaseEntity(Entity): self, include_value_name: bool = False, alternate_value_name: str | None = None, - additional_info: list[str] | None = None, + additional_info: list[str | None] | None = None, name_prefix: str | None = None, ) -> str: """Generate entity name.""" @@ -155,8 +155,11 @@ class ZWaveBaseEntity(Entity): or self.info.primary_value.property_name or "" ) + name = f"{name} {value_name}".strip() - name = f"{name} {' '.join(additional_info or [])}".strip() + # Only include non empty additional info + if additional_info := [item for item in (additional_info or []) if item]: + name = f"{name} {' '.join(additional_info)}" # append endpoint if > 1 if ( self.info.primary_value.endpoint is not None diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 313110169c3a..a9f83dcad428 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -468,6 +468,28 @@ class ZWaveMeterSensor(ZWaveNumericSensor): class ZWaveListSensor(ZwaveSensor): """Representation of a Z-Wave Numeric sensor with multiple states.""" + def __init__( + self, + config_entry: ConfigEntry, + driver: Driver, + info: ZwaveDiscoveryInfo, + entity_description: SensorEntityDescription, + unit_of_measurement: str | None = None, + ) -> None: + """Initialize a ZWaveListSensor entity.""" + super().__init__( + config_entry, driver, info, entity_description, unit_of_measurement + ) + + # Entity class attributes + # Notification sensors have the following name mapping (variables are property + # keys, name is property) + # https://github.com/zwave-js/node-zwave-js/blob/master/packages/config/config/notifications.json + self._attr_name = self.generate_name( + alternate_value_name=self.info.primary_value.property_name, + additional_info=[self.info.primary_value.property_key_name], + ) + @property def device_class(self) -> SensorDeviceClass | None: """Return sensor device class.""" diff --git a/tests/components/zwave_js/common.py b/tests/components/zwave_js/common.py index 49fbe96f1623..aee2322b2afb 100644 --- a/tests/components/zwave_js/common.py +++ b/tests/components/zwave_js/common.py @@ -24,7 +24,7 @@ LOW_BATTERY_BINARY_SENSOR = "binary_sensor.multisensor_6_low_battery_level" ENABLED_LEGACY_BINARY_SENSOR = "binary_sensor.z_wave_door_window_sensor_any" DISABLED_LEGACY_BINARY_SENSOR = "binary_sensor.multisensor_6_any" NOTIFICATION_MOTION_BINARY_SENSOR = "binary_sensor.multisensor_6_motion_detection" -NOTIFICATION_MOTION_SENSOR = "sensor.multisensor_6_motion_sensor_status" +NOTIFICATION_MOTION_SENSOR = "sensor.multisensor_6_home_security_motion_sensor_status" INDICATOR_SENSOR = "sensor.z_wave_thermostat_indicator_value" BASIC_NUMBER_ENTITY = "number.livingroomlight_basic" PROPERTY_DOOR_STATUS_BINARY_SENSOR = ( diff --git a/tests/components/zwave_js/test_button.py b/tests/components/zwave_js/test_button.py index 9e2c51872185..e4c33e313c6d 100644 --- a/tests/components/zwave_js/test_button.py +++ b/tests/components/zwave_js/test_button.py @@ -67,17 +67,20 @@ async def test_notification_idle_button( ) -> None: """Test Notification idle button.""" node = multisensor_6 - state = hass.states.get("button.multisensor_6_idle_cover_status") + state = hass.states.get("button.multisensor_6_idle_home_security_cover_status") assert state assert state.state == "unknown" - assert state.attributes["friendly_name"] == "Multisensor 6 Idle Cover status" + assert ( + state.attributes["friendly_name"] + == "Multisensor 6 Idle Home Security Cover status" + ) # Test successful idle call await hass.services.async_call( BUTTON_DOMAIN, SERVICE_PRESS, { - ATTR_ENTITY_ID: "button.multisensor_6_idle_cover_status", + ATTR_ENTITY_ID: "button.multisensor_6_idle_home_security_cover_status", }, blocking=True, ) diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 22cbe87d0b47..a6fbebf4b64c 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -1362,8 +1362,10 @@ async def test_disabled_entity_on_value_removed( er_reg = er.async_get(hass) # re-enable this default-disabled entity - sensor_cover_entity = "sensor.4_in_1_sensor_cover_status" - idle_cover_status_button_entity = "button.4_in_1_sensor_idle_cover_status" + sensor_cover_entity = "sensor.4_in_1_sensor_home_security_cover_status" + idle_cover_status_button_entity = ( + "button.4_in_1_sensor_idle_home_security_cover_status" + ) er_reg.async_update_entity(entity_id=sensor_cover_entity, disabled_by=None) await hass.async_block_till_done() From 66f7218b6863054116345e25ce263add789fa80f Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Wed, 24 May 2023 09:04:11 -0400 Subject: [PATCH 517/822] Do optimistic state update for Z-Wave multilevel switch entities (#90490) * Do optimistic state update for Z-Wave multilevel switch entities * simplify * define constant for setting value to previous value * Rework to only consider value of 255 and only places where we know that the previous state is known by the integration due to the service being called * missed commit * better code * Add tests and use constant from lib * fix logic * fix bug * Add comments with more details --- homeassistant/components/zwave_js/fan.py | 21 ++++++++- homeassistant/components/zwave_js/light.py | 22 +++++++-- tests/components/zwave_js/test_fan.py | 55 ++++++++++++---------- tests/components/zwave_js/test_light.py | 7 +++ 4 files changed, 74 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/zwave_js/fan.py b/homeassistant/components/zwave_js/fan.py index bb634328a069..ea00b70b9496 100644 --- a/homeassistant/components/zwave_js/fan.py +++ b/homeassistant/components/zwave_js/fan.py @@ -6,6 +6,7 @@ from typing import Any, cast from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const import TARGET_VALUE_PROPERTY, CommandClass +from zwave_js_server.const.command_class.multilevel_switch import SET_TO_PREVIOUS_VALUE from zwave_js_server.const.command_class.thermostat import ( THERMOSTAT_FAN_OFF_PROPERTY, THERMOSTAT_FAN_STATE_PROPERTY, @@ -88,6 +89,8 @@ class ZwaveFan(ZWaveBaseEntity, FanEntity): assert target_value self._target_value = target_value + self._use_optimistic_state: bool = False + async def async_set_percentage(self, percentage: int) -> None: """Set the speed percentage of the fan.""" if percentage == 0: @@ -111,8 +114,19 @@ class ZwaveFan(ZWaveBaseEntity, FanEntity): elif preset_mode is not None: await self.async_set_preset_mode(preset_mode) else: - # Value 255 tells device to return to previous value - await self.info.node.async_set_value(self._target_value, 255) + if self.info.primary_value.command_class != CommandClass.SWITCH_MULTILEVEL: + raise HomeAssistantError( + "`percentage` or `preset_mode` must be provided" + ) + # If this is a Multilevel Switch CC value, we do an optimistic state update + # when setting to a previous value to avoid waiting for the value to be + # updated from the device which is typically delayed and causes a confusing + # UX. + await self.info.node.async_set_value( + self._target_value, SET_TO_PREVIOUS_VALUE + ) + self._use_optimistic_state = True + self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" @@ -121,6 +135,9 @@ class ZwaveFan(ZWaveBaseEntity, FanEntity): @property def is_on(self) -> bool | None: """Return true if device is on (speed above 0).""" + if self._use_optimistic_state: + self._use_optimistic_state = False + return True if self.info.primary_value.value is None: # guard missing value return None diff --git a/homeassistant/components/zwave_js/light.py b/homeassistant/components/zwave_js/light.py index 549685873aa4..e7ef38e5c18a 100644 --- a/homeassistant/components/zwave_js/light.py +++ b/homeassistant/components/zwave_js/light.py @@ -22,6 +22,7 @@ from zwave_js_server.const.command_class.color_switch import ( TARGET_COLOR_PROPERTY, ColorComponent, ) +from zwave_js_server.const.command_class.multilevel_switch import SET_TO_PREVIOUS_VALUE from zwave_js_server.model.driver import Driver from zwave_js_server.model.value import Value @@ -164,6 +165,8 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): if self.supports_brightness_transition or self.supports_color_transition: self._attr_supported_features |= LightEntityFeature.TRANSITION + self._set_optimistic_state: bool = False + @callback def on_value_update(self) -> None: """Call when a watched value is added or updated.""" @@ -187,10 +190,11 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): @property def is_on(self) -> bool | None: """Return true if device is on (brightness above 0).""" + if self._set_optimistic_state: + self._set_optimistic_state = False + return True brightness = self.brightness - if brightness is None: - return None - return brightness > 0 + return brightness > 0 if brightness is not None else None @property def hs_color(self) -> tuple[float, float] | None: @@ -332,8 +336,7 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): if not self._target_brightness: return if brightness is None: - # Level 255 means to set it to previous value. - zwave_brightness = 255 + zwave_brightness = SET_TO_PREVIOUS_VALUE else: # Zwave multilevel switches use a range of [0, 99] to control brightness. zwave_brightness = byte_to_zwave_brightness(brightness) @@ -350,6 +353,15 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): await self.info.node.async_set_value( self._target_brightness, zwave_brightness, zwave_transition ) + # We do an optimistic state update when setting to a previous value + # to avoid waiting for the value to be updated from the device which is + # typically delayed and causes a confusing UX. + if ( + zwave_brightness == SET_TO_PREVIOUS_VALUE + and self.info.primary_value.command_class == CommandClass.SWITCH_MULTILEVEL + ): + self._set_optimistic_state = True + self.async_write_ha_state() @callback def _calculate_color_values(self) -> None: diff --git a/tests/components/zwave_js/test_fan.py b/tests/components/zwave_js/test_fan.py index 471246c59aab..e4ff285feb2c 100644 --- a/tests/components/zwave_js/test_fan.py +++ b/tests/components/zwave_js/test_fan.py @@ -43,7 +43,35 @@ async def test_generic_fan( state = hass.states.get(entity_id) assert state - assert state.state == "off" + assert state.state == STATE_OFF + + # Test turn on no speed + await hass.services.async_call( + "fan", + "turn_on", + {"entity_id": entity_id}, + 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"] == 17 + assert args["valueId"] == { + "commandClass": 38, + "endpoint": 0, + "property": "targetValue", + } + assert args["value"] == 255 + + client.async_send_command.reset_mock() + + # Due to optimistic updates, the state should be on even though the Z-Wave state + # hasn't been updated yet + state = hass.states.get(entity_id) + + assert state + assert state.state == STATE_ON # Test turn on setting speed await hass.services.async_call( @@ -77,27 +105,6 @@ async def test_generic_fan( client.async_send_command.reset_mock() - # Test turn on no speed - await hass.services.async_call( - "fan", - "turn_on", - {"entity_id": entity_id}, - 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"] == 17 - assert args["valueId"] == { - "commandClass": 38, - "endpoint": 0, - "property": "targetValue", - } - assert args["value"] == 255 - - client.async_send_command.reset_mock() - # Test turning off await hass.services.async_call( "fan", @@ -140,7 +147,7 @@ async def test_generic_fan( node.receive_event(event) state = hass.states.get(entity_id) - assert state.state == "on" + assert state.state == STATE_ON assert state.attributes[ATTR_PERCENTAGE] == 100 client.async_send_command.reset_mock() @@ -165,7 +172,7 @@ async def test_generic_fan( node.receive_event(event) state = hass.states.get(entity_id) - assert state.state == "off" + assert state.state == STATE_OFF assert state.attributes[ATTR_PERCENTAGE] == 0 client.async_send_command.reset_mock() diff --git a/tests/components/zwave_js/test_light.py b/tests/components/zwave_js/test_light.py index 44cb9d70f76f..3a862ee3a0c4 100644 --- a/tests/components/zwave_js/test_light.py +++ b/tests/components/zwave_js/test_light.py @@ -70,6 +70,13 @@ async def test_light( } assert args["value"] == 255 + # Due to optimistic updates, the state should be on even though the Z-Wave state + # hasn't been updated yet + state = hass.states.get(BULB_6_MULTI_COLOR_LIGHT_ENTITY) + + assert state + assert state.state == STATE_ON + client.async_send_command.reset_mock() # Test turning on with transition From 872cd47e87acfb932428422c7271533b19f0a274 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Wed, 24 May 2023 09:09:38 -0400 Subject: [PATCH 518/822] Add proper support for zwave_js Indicator CC (#90248) * Add proper support for zwave_js Indicator CC * remove stale test * Make all indicators diagnostic * only set entity category if it is specified * Only set properties from discovery if specified * Conditionally set assumed state as well * fix const name * Don't create task * Disable property keys 3-5 by default * add additional dispatcher_connects so we catch all signals * be consistent about order * rename new discovery parameter * comment * exclude property keys 3-5 * fix remove logic * add comment so I don't forget * Switch entity category to config where necessary * cut line * less lines * Update homeassistant/components/zwave_js/switch.py Co-authored-by: kpine * Move async_remove to respond to interview started event * Set up listener immediately so we don't wait for platform creation * remove dupe import * black * append --------- Co-authored-by: kpine --- homeassistant/components/zwave_js/__init__.py | 23 +- homeassistant/components/zwave_js/button.py | 20 + .../components/zwave_js/discovery.py | 93 +- homeassistant/components/zwave_js/entity.py | 23 +- homeassistant/components/zwave_js/number.py | 4 +- homeassistant/components/zwave_js/sensor.py | 3 + homeassistant/components/zwave_js/switch.py | 14 + homeassistant/components/zwave_js/update.py | 2 +- tests/components/zwave_js/conftest.py | 47 +- .../fixtures/indicator_test_state.json | 190 +++ .../fixtures/switch_zooz_zen72_state.json | 1299 +++++++++++++++++ tests/components/zwave_js/test_api.py | 51 +- tests/components/zwave_js/test_diagnostics.py | 2 +- tests/components/zwave_js/test_discovery.py | 154 +- tests/components/zwave_js/test_init.py | 4 +- tests/components/zwave_js/test_sensor.py | 13 - 16 files changed, 1862 insertions(+), 80 deletions(-) create mode 100644 tests/components/zwave_js/fixtures/indicator_test_state.json create mode 100644 tests/components/zwave_js/fixtures/switch_zooz_zen72_state.json diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index 66839026dd43..8d118fd89f55 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -321,6 +321,19 @@ class ControllerEvents: async def async_on_node_added(self, node: ZwaveNode) -> None: """Handle node added event.""" + # Remove stale entities that may exist from a previous interview when an + # interview is started. + base_unique_id = get_valueless_base_unique_id(self.driver_events.driver, node) + self.config_entry.async_on_unload( + node.on( + "interview started", + lambda _: async_dispatcher_send( + self.hass, + f"{DOMAIN}_{base_unique_id}_remove_entity_on_interview_started", + ), + ) + ) + # No need for a ping button or node status sensor for controller nodes if not node.is_controller_node: # Create a node status sensor for each device @@ -455,7 +468,6 @@ class NodeEvents: async def async_on_node_ready(self, node: ZwaveNode) -> None: """Handle node ready event.""" LOGGER.debug("Processing node %s", node) - driver = self.controller_events.driver_events.driver # register (or update) node in device registry device = self.controller_events.register_node_in_dev_reg(node) # We only want to create the defaultdict once, even on reinterviews @@ -464,15 +476,6 @@ class NodeEvents: # Remove any old value ids if this is a reinterview. self.controller_events.discovered_value_ids.pop(device.id, None) - # Remove stale entities that may exist from a previous interview. - async_dispatcher_send( - self.hass, - ( - f"{DOMAIN}_" - f"{get_valueless_base_unique_id(driver, node)}_" - "remove_entity_on_ready_node" - ), - ) value_updates_disc_info: dict[str, ZwaveDiscoveryInfo] = {} diff --git a/homeassistant/components/zwave_js/button.py b/homeassistant/components/zwave_js/button.py index ae6cca03c3d3..7a6ec4c276c0 100644 --- a/homeassistant/components/zwave_js/button.py +++ b/homeassistant/components/zwave_js/button.py @@ -36,6 +36,8 @@ async def async_setup_entry( entities: list[ZWaveBaseEntity] = [] if info.platform_hint == "notification idle": entities.append(ZWaveNotificationIdleButton(config_entry, driver, info)) + else: + entities.append(ZwaveBooleanNodeButton(config_entry, driver, info)) async_add_entities(entities) @@ -63,6 +65,21 @@ async def async_setup_entry( ) +class ZwaveBooleanNodeButton(ZWaveBaseEntity, ButtonEntity): + """Representation of a ZWave button entity for a boolean value.""" + + def __init__( + self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + ) -> None: + """Initialize entity.""" + super().__init__(config_entry, driver, info) + self._attr_name = self.generate_name(include_value_name=True) + + async def async_press(self) -> None: + """Press the button.""" + await self.info.node.async_set_value(self.info.primary_value, True) + + class ZWaveNodePingButton(ButtonEntity): """Representation of a ping button entity.""" @@ -98,6 +115,9 @@ class ZWaveNodePingButton(ButtonEntity): ) ) + # we don't listen for `remove_entity_on_ready_node` signal because this entity + # is created when the node is added which occurs before ready. It only needs to + # be removed if the node is removed from the network. self.async_on_remove( async_dispatcher_connect( self.hass, diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 3d7139945fa8..4e7d7f7748a4 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -44,7 +44,7 @@ from zwave_js_server.model.node import Node as ZwaveNode from zwave_js_server.model.value import Value as ZwaveValue from homeassistant.backports.enum import StrEnum -from homeassistant.const import Platform +from homeassistant.const import EntityCategory, Platform from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceEntry @@ -108,7 +108,8 @@ class ZwaveDiscoveryInfo: node: ZwaveNode # the value object itself for primary value primary_value: ZwaveValue - # bool to specify whether state is assumed and events should be fired on value update + # bool to specify whether state is assumed and events should be fired on value + # update assumed_state: bool # the home assistant platform for which an entity should be created platform: Platform @@ -122,6 +123,8 @@ class ZwaveDiscoveryInfo: platform_data_template: BaseDiscoverySchemaDataTemplate | None = None # bool to specify whether entity should be enabled by default entity_registry_enabled_default: bool = True + # the entity category for the discovered entity + entity_category: EntityCategory | None = None @dataclass @@ -143,8 +146,14 @@ class ZWaveValueDiscoverySchema(DataclassMustHaveAtLeastOne): property_name: set[str] | None = None # [optional] the value's property key must match ANY of these values property_key: set[str | int | None] | None = None + # [optional] the value's property key must NOT match ANY of these values + not_property_key: set[str | int | None] | None = None # [optional] the value's metadata_type must match ANY of these values type: set[str] | None = None + # [optional] the value's metadata_readable must match this value + readable: bool | None = None + # [optional] the value's metadata_writeable must match this value + 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 @@ -192,6 +201,8 @@ class ZWaveDiscoverySchema: assumed_state: bool = False # [optional] bool to specify whether entity should be enabled by default entity_registry_enabled_default: bool = True + # [optional] the entity category for the discovered entity + entity_category: EntityCategory | None = None def get_config_parameter_discovery_schema( @@ -695,6 +706,18 @@ DISCOVERY_SCHEMAS = [ ), allow_multi=True, ), + # binary sensor for Indicator CC + ZWaveDiscoverySchema( + platform=Platform.BINARY_SENSOR, + hint="boolean", + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.INDICATOR}, + type={ValueType.BOOLEAN}, + readable=True, + writeable=False, + ), + entity_category=EntityCategory.DIAGNOSTIC, + ), # generic text sensors ZWaveDiscoverySchema( platform=Platform.SENSOR, @@ -704,15 +727,6 @@ DISCOVERY_SCHEMAS = [ type={ValueType.STRING}, ), ), - ZWaveDiscoverySchema( - platform=Platform.SENSOR, - hint="string_sensor", - primary_value=ZWaveValueDiscoverySchema( - command_class={CommandClass.INDICATOR}, - type={ValueType.STRING}, - ), - entity_registry_enabled_default=False, - ), # generic numeric sensors ZWaveDiscoverySchema( platform=Platform.SENSOR, @@ -733,9 +747,11 @@ DISCOVERY_SCHEMAS = [ primary_value=ZWaveValueDiscoverySchema( command_class={CommandClass.INDICATOR}, type={ValueType.NUMBER}, + readable=True, + writeable=False, ), data_template=NumericSensorDataTemplate(), - entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), # Meter sensors for Meter CC ZWaveDiscoverySchema( @@ -768,9 +784,7 @@ DISCOVERY_SCHEMAS = [ platform=Platform.NUMBER, hint="Basic", primary_value=ZWaveValueDiscoverySchema( - command_class={ - CommandClass.BASIC, - }, + command_class={CommandClass.BASIC}, type={ValueType.NUMBER}, property={CURRENT_VALUE_PROPERTY}, ), @@ -783,14 +797,48 @@ DISCOVERY_SCHEMAS = [ property={TARGET_VALUE_PROPERTY}, ) ], - data_template=NumericSensorDataTemplate(), entity_registry_enabled_default=False, ), + # number for Indicator CC (exclude property keys 3-5) + ZWaveDiscoverySchema( + platform=Platform.NUMBER, + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.INDICATOR}, + type={ValueType.NUMBER}, + not_property_key={3, 4, 5}, + readable=True, + writeable=True, + ), + entity_category=EntityCategory.CONFIG, + ), + # button for Indicator CC + ZWaveDiscoverySchema( + platform=Platform.BUTTON, + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.INDICATOR}, + type={ValueType.BOOLEAN}, + readable=False, + writeable=True, + ), + entity_category=EntityCategory.CONFIG, + ), # binary switches ZWaveDiscoverySchema( platform=Platform.SWITCH, primary_value=SWITCH_BINARY_CURRENT_VALUE_SCHEMA, ), + # switch for Indicator CC + ZWaveDiscoverySchema( + platform=Platform.SWITCH, + hint="indicator", + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.INDICATOR}, + type={ValueType.BOOLEAN}, + readable=True, + writeable=True, + ), + entity_category=EntityCategory.CONFIG, + ), # binary switch # barrier operator signaling states ZWaveDiscoverySchema( @@ -1023,6 +1071,7 @@ def async_discover_single_value( platform_data=resolved_data, additional_value_ids_to_watch=additional_value_ids_to_watch, entity_registry_enabled_default=schema.entity_registry_enabled_default, + entity_category=schema.entity_category, ) if not schema.allow_multi: @@ -1058,9 +1107,21 @@ def check_value(value: ZwaveValue, schema: ZWaveValueDiscoverySchema) -> bool: and value.property_key not in schema.property_key ): return False + # check property_key against not_property_key set + if ( + schema.not_property_key is not None + and value.property_key in schema.not_property_key + ): + return False # check metadata_type if schema.type is not None and value.metadata.type not in schema.type: return False + # check metadata_readable + if schema.readable is not None and value.metadata.readable != schema.readable: + return False + # check metadata_writeable + if schema.writeable is not None and value.metadata.writeable != schema.writeable: + return False # check available states if ( schema.any_available_states is not None diff --git a/homeassistant/components/zwave_js/entity.py b/homeassistant/components/zwave_js/entity.py index 379fe4a18257..08e997170d7f 100644 --- a/homeassistant/components/zwave_js/entity.py +++ b/homeassistant/components/zwave_js/entity.py @@ -44,10 +44,12 @@ class ZWaveBaseEntity(Entity): # Entity class attributes self._attr_name = self.generate_name() self._attr_unique_id = get_unique_id(driver, self.info.primary_value.value_id) - self._attr_entity_registry_enabled_default = ( - self.info.entity_registry_enabled_default - ) - self._attr_assumed_state = self.info.assumed_state + if self.info.entity_registry_enabled_default is False: + self._attr_entity_registry_enabled_default = False + if self.info.entity_category is not None: + self._attr_entity_category = self.info.entity_category + if self.info.assumed_state: + self._attr_assumed_state = True # device is precreated in main handler self._attr_device_info = DeviceInfo( identifiers={get_device_id(driver, self.info.node)}, @@ -103,7 +105,18 @@ class ZWaveBaseEntity(Entity): ( f"{DOMAIN}_" f"{get_valueless_base_unique_id(self.driver, self.info.node)}_" - "remove_entity_on_ready_node" + "remove_entity" + ), + self.async_remove, + ) + ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + ( + f"{DOMAIN}_" + f"{get_valueless_base_unique_id(self.driver, self.info.node)}_" + "remove_entity_on_interview_started" ), self.async_remove, ) diff --git a/homeassistant/components/zwave_js/number.py b/homeassistant/components/zwave_js/number.py index a6a219fa7c63..52302c64b33b 100644 --- a/homeassistant/components/zwave_js/number.py +++ b/homeassistant/components/zwave_js/number.py @@ -66,7 +66,9 @@ class ZwaveNumberEntity(ZWaveBaseEntity, NumberEntity): self._target_value = self.get_zwave_value(TARGET_VALUE_PROPERTY) # Entity class attributes - self._attr_name = self.generate_name(alternate_value_name=info.platform_hint) + self._attr_name = self.generate_name( + include_value_name=True, alternate_value_name=info.platform_hint + ) @property def native_min_value(self) -> float: diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index a9f83dcad428..469f66b792b0 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -615,6 +615,9 @@ class ZWaveNodeStatusSensor(SensorEntity): self.async_poll_value, ) ) + # we don't listen for `remove_entity_on_ready_node` signal because this entity + # is created when the node is added which occurs before ready. It only needs to + # be removed if the node is removed from the network. self.async_on_remove( async_dispatcher_connect( self.hass, diff --git a/homeassistant/components/zwave_js/switch.py b/homeassistant/components/zwave_js/switch.py index 450a0cfd31d9..ce640f92140d 100644 --- a/homeassistant/components/zwave_js/switch.py +++ b/homeassistant/components/zwave_js/switch.py @@ -41,6 +41,8 @@ async def async_setup_entry( entities.append( ZWaveBarrierEventSignalingSwitch(config_entry, driver, info) ) + elif info.platform_hint == "indicator": + entities.append(ZWaveIndicatorSwitch(config_entry, driver, info)) else: entities.append(ZWaveSwitch(config_entry, driver, info)) @@ -85,6 +87,18 @@ class ZWaveSwitch(ZWaveBaseEntity, SwitchEntity): await self.info.node.async_set_value(self._target_value, False) +class ZWaveIndicatorSwitch(ZWaveSwitch): + """Representation of a Z-Wave Indicator CC switch.""" + + def __init__( + self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + ) -> None: + """Initialize the switch.""" + super().__init__(config_entry, driver, info) + self._target_value = self.info.primary_value + self._attr_name = self.generate_name(include_value_name=True) + + class ZWaveBarrierEventSignalingSwitch(ZWaveBaseEntity, SwitchEntity): """Switch is used to turn on/off a barrier device's event signaling subsystem.""" diff --git a/homeassistant/components/zwave_js/update.py b/homeassistant/components/zwave_js/update.py index 70d12b22dedc..243642086d02 100644 --- a/homeassistant/components/zwave_js/update.py +++ b/homeassistant/components/zwave_js/update.py @@ -317,7 +317,7 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity): self.async_on_remove( async_dispatcher_connect( self.hass, - f"{DOMAIN}_{self._base_unique_id}_remove_entity_on_ready_node", + f"{DOMAIN}_{self._base_unique_id}_remove_entity_on_interview_started", self.async_remove, ) ) diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index 32082a0bb858..d071d9379157 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -11,6 +11,8 @@ from zwave_js_server.model.driver import Driver from zwave_js_server.model.node import Node from zwave_js_server.version import VersionInfo +from homeassistant.core import HomeAssistant + from tests.common import MockConfigEntry, load_fixture # Add-on fixtures @@ -109,7 +111,7 @@ def mock_addon_options(addon_info): def set_addon_options_side_effect_fixture(addon_options): """Return the set add-on options side effect.""" - async def set_addon_options(hass, slug, options): + async def set_addon_options(hass: HomeAssistant, slug, options): """Mock set add-on options.""" addon_options.update(options["options"]) @@ -130,7 +132,7 @@ def mock_set_addon_options(set_addon_options_side_effect): def install_addon_side_effect_fixture(addon_store_info, addon_info): """Return the install add-on side effect.""" - async def install_addon(hass, slug): + async def install_addon(hass: HomeAssistant, slug): """Mock install add-on.""" addon_store_info.return_value = { "available": True, @@ -168,7 +170,7 @@ def mock_update_addon(): def start_addon_side_effect_fixture(addon_store_info, addon_info): """Return the start add-on options side effect.""" - async def start_addon(hass, slug): + async def start_addon(hass: HomeAssistant, slug): """Mock start add-on.""" addon_store_info.return_value = { "available": True, @@ -365,7 +367,7 @@ def climate_adc_t3000_state_fixture(): @pytest.fixture(name="climate_danfoss_lc_13_state", scope="session") def climate_danfoss_lc_13_state_fixture(): - """Load the climate Danfoss (LC-13) electronic radiator thermostat node state fixture data.""" + """Load Danfoss (LC-13) electronic radiator thermostat node state fixture data.""" return json.loads(load_fixture("zwave_js/climate_danfoss_lc_13_state.json")) @@ -522,7 +524,7 @@ def climate_radio_thermostat_ct101_multiple_temp_units_state_fixture(): scope="session", ) def climate_radio_thermostat_ct100_mode_and_setpoint_on_different_endpoints_state_fixture(): - """Load the climate device with mode and setpoint on different endpoints node state fixture data.""" + """Load climate device w/ mode+setpoint on diff endpoints node state fixture data.""" return json.loads( load_fixture( "zwave_js/climate_radio_thermostat_ct100_mode_and_setpoint_on_different_endpoints_state.json" @@ -604,6 +606,18 @@ def lock_home_connect_620_state_fixture(): return json.loads(load_fixture("zwave_js/lock_home_connect_620_state.json")) +@pytest.fixture(name="switch_zooz_zen72_state", scope="session") +def switch_zooz_zen72_state_fixture(): + """Load the Zooz Zen72 switch node state fixture data.""" + return json.loads(load_fixture("zwave_js/switch_zooz_zen72_state.json")) + + +@pytest.fixture(name="indicator_test_state", scope="session") +def indicator_test_state_fixture(): + """Load the indicator CC test node state fixture data.""" + return json.loads(load_fixture("zwave_js/indicator_test_state.json")) + + # model fixtures @@ -612,7 +626,6 @@ def mock_client_fixture( controller_state, controller_node_state, version_state, log_config_state ): """Mock a client.""" - with patch( "homeassistant.components.zwave_js.ZwaveClient", autospec=True ) as client_class: @@ -722,7 +735,7 @@ def climate_radio_thermostat_ct100_plus_fixture( def climate_radio_thermostat_ct100_plus_different_endpoints_fixture( client, climate_radio_thermostat_ct100_plus_different_endpoints_state ): - """Mock a climate radio thermostat ct100 plus node with values on different endpoints.""" + """Mock climate radio thermostat ct100 plus node w/ values on diff endpoints.""" node = Node( client, copy.deepcopy(climate_radio_thermostat_ct100_plus_different_endpoints_state), @@ -773,7 +786,7 @@ def climate_adc_t3000_missing_mode_fixture(client, climate_adc_t3000_state): @pytest.fixture(name="climate_adc_t3000_missing_fan_mode_states") def climate_adc_t3000_missing_fan_mode_states_fixture(client, climate_adc_t3000_state): - """Mock a climate ADC-T3000 node with missing 'states' metadata on Thermostat Fan Mode.""" + """Mock ADC-T3000 node w/ missing 'states' metadata on Thermostat Fan Mode.""" data = copy.deepcopy(climate_adc_t3000_state) data["name"] = f"{data['name']} missing fan mode states" for value in data["values"]: @@ -872,7 +885,7 @@ def nortek_thermostat_removed_event_fixture(client): @pytest.fixture(name="integration") -async def integration_fixture(hass, client): +async def integration_fixture(hass: HomeAssistant, client): """Set up the zwave_js integration.""" entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) entry.add_to_hass(hass) @@ -1146,3 +1159,19 @@ def lock_home_connect_620_fixture(client, lock_home_connect_620_state): node = Node(client, copy.deepcopy(lock_home_connect_620_state)) client.driver.controller.nodes[node.node_id] = node return node + + +@pytest.fixture(name="switch_zooz_zen72") +def switch_zooz_zen72_fixture(client, switch_zooz_zen72_state): + """Mock a Zooz Zen72 switch node.""" + node = Node(client, copy.deepcopy(switch_zooz_zen72_state)) + client.driver.controller.nodes[node.node_id] = node + return node + + +@pytest.fixture(name="indicator_test") +def indicator_test_fixture(client, indicator_test_state): + """Mock a indicator CC test node.""" + node = Node(client, copy.deepcopy(indicator_test_state)) + client.driver.controller.nodes[node.node_id] = node + return node diff --git a/tests/components/zwave_js/fixtures/indicator_test_state.json b/tests/components/zwave_js/fixtures/indicator_test_state.json new file mode 100644 index 000000000000..9e823b79109f --- /dev/null +++ b/tests/components/zwave_js/fixtures/indicator_test_state.json @@ -0,0 +1,190 @@ +{ + "nodeId": 43, + "index": 0, + "installerIcon": 1536, + "userIcon": 1540, + "status": 4, + "ready": true, + "isListening": true, + "isRouting": true, + "isSecure": true, + "manufacturerId": 634, + "productId": 40962, + "productType": 28672, + "firmwareVersion": "10.0.1", + "zwavePlusVersion": 2, + "location": "**REDACTED**", + "deviceConfig": { + "filename": "/usr/src/app/store/.config-db/devices/0x027a/zen72.json", + "isEmbedded": true, + "manufacturer": "Fake", + "manufacturerId": 634, + "label": "Device", + "description": "This is a fake device", + "devices": [ + { + "productType": 28672, + "productId": 40962 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "associations": {}, + "paramInformation": { + "_map": {} + }, + "compat": { + "skipConfigurationNameQuery": true, + "skipConfigurationInfoQuery": true + }, + "metadata": { + "inclusion": "1. Initiate inclusion (pairing) in the app (or web interface).\n2. TAP UP 3 TIMES QUICKLY if using traditional Z-Wave inclusion.\n3. The LED indicator will blink blue to signal communication and turn green for 3 seconds if inclusion is successful or turn red for 3 seconds if the pairing attempt fails", + "exclusion": "1. Bring your Z-Wave gateway (hub) close to the switch if possible\n2. Put the Z-Wave hub into exclusion mode (not sure how to do that? ask@getzooz.com) \n3. Tap the lower paddle on the switch 3 times quickly (the LED indicator will start blinking blue)\n4. Your hub will confirm exclusion, the LED indicator on the switch will turn green for 3 seconds, and the device will disappear from your controller's device list", + "reset": "If your primary controller is missing or inoperable, you may need to reset the device to factory settings. To reset the switch, press and hold the lower paddle for 10 seconds until the LED indicator starts blinking. Release paddle, and immediately after, tap the lower paddle 5 times to complete the reset. The LED indicator will flash blue 3 times and turn red for 3 seconds to confirm successful reset", + "manual": "https://products.z-wavealliance.org/ProductManual/File?folder=&filename=product_documents/4108/zooz-700-series-z-wave-dimmer-zen72-manual.pdf" + } + }, + "label": "Device", + "interviewAttempts": 0, + "endpoints": [ + { + "nodeId": 43, + "index": 0, + "installerIcon": 1536, + "userIcon": 1540, + "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": 135, + "name": "Indicator", + "version": 3, + "isSecure": true + } + ] + } + ], + "values": [ + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": "Test", + "propertyKey": "Sensor", + "propertyName": "Test", + "propertyKeyName": "Sensor", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "description": "Tests non-writeable Indicator CC number gets created as a sensor entity", + "label": "Sensor", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 4 + } + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": "Test", + "propertyKey": "Switch", + "propertyName": "Test", + "propertyKeyName": "Switch", + "ccVersion": 3, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "description": "Tests writeable Indicator CC boolean gets created as a switch entity", + "label": "Switch", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 5 + } + }, + "value": false + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": "Test", + "propertyKey": "Binary Sensor", + "propertyName": "Test", + "propertyKeyName": "Binary Sensor", + "ccVersion": 3, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "description": "Tests non-writeable Indicator CC boolean gets created as a binary sensor entity", + "label": "Binary Sensor", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 5 + } + }, + "value": 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": 1, + "label": "Multilevel Power Switch" + }, + "mandatorySupportedCCs": [32, 38, 39], + "mandatoryControlledCCs": [] + }, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x027a:0x7000:0xa002:10.0.1", + "statistics": { + "commandsTX": 64, + "commandsRX": 88, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 0, + "rtt": 74.5, + "rssi": -60 + }, + "highestSecurityClass": 1, + "isControllerNode": false, + "keepAwake": false +} diff --git a/tests/components/zwave_js/fixtures/switch_zooz_zen72_state.json b/tests/components/zwave_js/fixtures/switch_zooz_zen72_state.json new file mode 100644 index 000000000000..04b3a336618e --- /dev/null +++ b/tests/components/zwave_js/fixtures/switch_zooz_zen72_state.json @@ -0,0 +1,1299 @@ +{ + "nodeId": 43, + "index": 0, + "installerIcon": 1536, + "userIcon": 1540, + "status": 4, + "ready": true, + "isListening": true, + "isRouting": true, + "isSecure": true, + "manufacturerId": 634, + "productId": 40962, + "productType": 28672, + "firmwareVersion": "10.0.1", + "zwavePlusVersion": 2, + "location": "**REDACTED**", + "deviceConfig": { + "filename": "/usr/src/app/store/.config-db/devices/0x027a/zen72.json", + "isEmbedded": true, + "manufacturer": "Zooz", + "manufacturerId": 634, + "label": "ZEN72", + "description": "Z-Wave Plus 700 Series Dimmer Switch", + "devices": [ + { + "productType": 28672, + "productId": 40962 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "associations": {}, + "paramInformation": { + "_map": {} + }, + "compat": { + "skipConfigurationNameQuery": true, + "skipConfigurationInfoQuery": true + }, + "metadata": { + "inclusion": "1. Initiate inclusion (pairing) in the app (or web interface).\n2. TAP UP 3 TIMES QUICKLY if using traditional Z-Wave inclusion.\n3. The LED indicator will blink blue to signal communication and turn green for 3 seconds if inclusion is successful or turn red for 3 seconds if the pairing attempt fails", + "exclusion": "1. Bring your Z-Wave gateway (hub) close to the switch if possible\n2. Put the Z-Wave hub into exclusion mode (not sure how to do that? ask@getzooz.com) \n3. Tap the lower paddle on the switch 3 times quickly (the LED indicator will start blinking blue)\n4. Your hub will confirm exclusion, the LED indicator on the switch will turn green for 3 seconds, and the device will disappear from your controller's device list", + "reset": "If your primary controller is missing or inoperable, you may need to reset the device to factory settings. To reset the switch, press and hold the lower paddle for 10 seconds until the LED indicator starts blinking. Release paddle, and immediately after, tap the lower paddle 5 times to complete the reset. The LED indicator will flash blue 3 times and turn red for 3 seconds to confirm successful reset", + "manual": "https://products.z-wavealliance.org/ProductManual/File?folder=&filename=product_documents/4108/zooz-700-series-z-wave-dimmer-zen72-manual.pdf" + } + }, + "label": "ZEN72", + "interviewAttempts": 0, + "endpoints": [ + { + "nodeId": 43, + "index": 0, + "installerIcon": 1536, + "userIcon": 1540, + "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": 38, + "name": "Multilevel Switch", + "version": 4, + "isSecure": true + }, + { + "id": 159, + "name": "Security 2", + "version": 1, + "isSecure": true + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 85, + "name": "Transport Service", + "version": 2, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 3, + "isSecure": true + }, + { + "id": 112, + "name": "Configuration", + "version": 4, + "isSecure": true + }, + { + "id": 91, + "name": "Central Scene", + "version": 3, + "isSecure": true + }, + { + "id": 133, + "name": "Association", + "version": 3, + "isSecure": true + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 4, + "isSecure": true + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": true + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 2, + "isSecure": true + }, + { + "id": 90, + "name": "Device Reset Locally", + "version": 1, + "isSecure": true + }, + { + "id": 135, + "name": "Indicator", + "version": 3, + "isSecure": true + }, + { + "id": 115, + "name": "Powerlevel", + "version": 1, + "isSecure": true + }, + { + "id": 122, + "name": "Firmware Update Meta Data", + "version": 5, + "isSecure": true + } + ] + } + ], + "values": [ + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 99 + }, + "value": 29 + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 4, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration" + }, + "value": { + "value": 3, + "unit": "seconds" + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Current value", + "min": 0, + "max": 99 + }, + "value": 99 + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Up", + "propertyName": "Up", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Up)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"] + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Down", + "propertyName": "Down", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Down)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"] + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "restorePrevious", + "propertyName": "restorePrevious", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Restore previous value" + } + }, + { + "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", + "5": "KeyPressed4x", + "6": "KeyPressed5x" + } + } + }, + { + "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", + "5": "KeyPressed4x", + "6": "KeyPressed5x" + } + } + }, + { + "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" + } + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 1, + "propertyName": "Inverted Orientation", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Inverted Orientation", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "Disable", + "1": "Enable", + "2": "Momentary mode" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 2, + "propertyName": "LED Indicator", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "LED Indicator", + "default": 0, + "min": 0, + "max": 3, + "states": { + "0": "On when load is off", + "1": "On when load is on", + "2": "Always off", + "3": "Always on" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyName": "LED Indicator Color", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "LED Indicator Color", + "default": 1, + "min": 0, + "max": 3, + "states": { + "0": "White", + "1": "Blue", + "2": "Green", + "3": "Red" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 24, + "propertyName": "LED Indicator Brightness", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "LED Indicator Brightness", + "default": 1, + "min": 0, + "max": 2, + "states": { + "0": "Bright (100%)", + "1": "Medium (60%)", + "2": "Low (30%)" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 3, + "propertyName": "Auto Turn-Off Timer", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Auto Turn-Off Timer", + "default": 0, + "min": 0, + "max": 65535, + "states": { + "0": "Disable" + }, + "unit": "minutes", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 5, + "propertyName": "Auto Turn-On Timer", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Auto Turn-On Timer", + "default": 0, + "min": 0, + "max": 65535, + "states": { + "0": "Disable" + }, + "unit": "minutes", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 8, + "propertyName": "State After Power Failure", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "State After Power Failure", + "default": 2, + "min": 0, + "max": 2, + "states": { + "0": "Always off", + "1": "Always on", + "2": "Previous state" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 9, + "propertyName": "Ramp Rate Off/On (Manual Control)", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Ramp Rate Off/On (Manual Control)", + "default": 1, + "min": 0, + "max": 99, + "states": { + "0": "Instant on/off" + }, + "unit": "seconds", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 10, + "propertyName": "Minimum Brightness", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Minimum Brightness", + "default": 1, + "min": 1, + "max": 99, + "unit": "%", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 10 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 11, + "propertyName": "Maximum Brightness", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Maximum Brightness", + "default": 99, + "min": 1, + "max": 99, + "unit": "%", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 99 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 12, + "propertyName": "Double-Tap Upper Paddle Behavior", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Double-Tap Upper Paddle Behavior", + "default": 0, + "min": 0, + "max": 3, + "states": { + "0": "Full brightness", + "1": "Configured custom brightness (parameter 18)", + "2": "Configured maximum brightness", + "3": "Double tap disabled" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 25, + "propertyName": "Single-Tap Upper Paddle Behavior", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Single-Tap Upper Paddle Behavior", + "default": 0, + "min": 0, + "max": 3, + "states": { + "0": "Previous brightness", + "1": "Configured custom brightness (parameter 18)", + "2": "Configured maximum brightness", + "3": "Full brightness" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 13, + "propertyName": "Enable/Disable Scene Control", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Enable or disable scene control functionality for quick double tap triggers.", + "label": "Enable/Disable Scene Control", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 15, + "propertyName": "Smart Switch Mode", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Smart Switch Mode", + "default": 1, + "min": 0, + "max": 2, + "states": { + "0": "Local control disabled", + "1": "Local and Z-Wave control enabled", + "2": "Local and Z-Wave control disabled" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 19, + "propertyName": "3-Way Switch Type", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "3-Way Switch Type", + "default": 0, + "min": 0, + "max": 3, + "states": { + "0": "Toggle switch", + "1": "Toggle switch (with dimming control)", + "2": "Momentary switch (with dimming control)", + "3": "Momentary switch (with dimming control, but reduce after double click)" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 20, + "propertyName": "Smart Bulb Mode: Dimming Reporting Behavior", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Reporting behavior for local control and Z-Wave disabled / enabled, respectively. Final level for Z-Wave control actions is always sent as a Multilevel Switch report, regardless of setting.", + "label": "Smart Bulb Mode: Dimming Reporting Behavior", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Each brightness level / Final level", + "1": "Final brightness level only for local dimming, regardless of control setting" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 21, + "propertyName": "Smart Bulb Mode: Reporting Behavior", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Smart Bulb Mode: Reporting Behavior", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Switch always reports status and changes LED state", + "1": "No status or LED state change when physical (and Z-Wave) control disabled" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 16, + "propertyName": "Local Dimming Speed", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "The time it takes to get from 0% to 100% brightness when pressing and holding the paddle.", + "label": "Local Dimming Speed", + "default": 5, + "min": 1, + "max": 99, + "unit": "seconds", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 18, + "propertyName": "Custom Brightness Level", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Custom Brightness Level", + "default": 0, + "min": 0, + "max": 99, + "states": { + "0": "Previous brightness" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 50 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 22, + "propertyName": "Night Light Mode", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Set the brightness level the dimmer will turn on to when off and when lower paddle is held down for one second.", + "label": "Night Light Mode", + "default": 20, + "min": 0, + "max": 99, + "states": { + "0": "Disable" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 25 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 26, + "propertyName": "Local Programming", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Controls programming (eg. including/excluding) device through physical inputs, allowing the according input sequence to be used for scene control instead. Factory reset remains available.", + "label": "Local Programming", + "default": 1, + "min": 0, + "max": 1, + "states": { + "0": "Enable", + "1": "Disable" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "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 + }, + "value": 634 + }, + { + "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 + }, + "value": 28672 + }, + { + "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 + }, + "value": 40962 + }, + { + "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" + } + }, + "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" + }, + "value": "7.13" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 3, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions" + }, + "value": ["10.0"] + }, + { + "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" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "sdkVersion", + "propertyName": "sdkVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "SDK version" + }, + "value": "7.13.9" + }, + { + "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" + }, + "value": "10.13.9" + }, + { + "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" + }, + "value": 405 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceVersion", + "propertyName": "hostInterfaceVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API version" + }, + "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" + }, + "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" + }, + "value": "7.13.9" + }, + { + "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" + }, + "value": 405 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationVersion", + "propertyName": "applicationVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application version" + }, + "value": "10.0.1" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationBuildNumber", + "propertyName": "applicationBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application build number" + }, + "value": 43707 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 3, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "Node Identify - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 3 + } + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 4, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "Node Identify - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 4 + } + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 5, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "Node Identify - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 5 + } + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": "value", + "propertyName": "value", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Indicator value", + "ccSpecific": { + "indicatorId": 0 + }, + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": "identify", + "propertyName": "identify", + "ccVersion": 3, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Identify" + } + } + ], + "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": 1, + "label": "Multilevel Power Switch" + }, + "mandatorySupportedCCs": [32, 38, 39], + "mandatoryControlledCCs": [] + }, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x027a:0x7000:0xa002:10.0.1", + "statistics": { + "commandsTX": 64, + "commandsRX": 88, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 0, + "rtt": 74.5, + "rssi": -60 + }, + "highestSecurityClass": 1, + "isControllerNode": false, + "keepAwake": false +} diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index f8a7a68f1399..b3dd4e34ee87 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -85,6 +85,8 @@ from homeassistant.helpers import device_registry as dr from tests.common import MockUser from tests.typing import ClientSessionGenerator, WebSocketGenerator +CONTROLLER_PATCH_PREFIX = "zwave_js_server.model.controller.Controller" + def get_device(hass: HomeAssistant, node): """Get device ID for a node.""" @@ -131,7 +133,7 @@ async def test_network_status( # Try API call with entry ID with patch( - "zwave_js_server.model.controller.Controller.async_get_state", + f"{CONTROLLER_PATCH_PREFIX}.async_get_state", return_value=controller_state["controller"], ): await ws_client.send_json( @@ -155,7 +157,7 @@ async def test_network_status( ) assert device with patch( - "zwave_js_server.model.controller.Controller.async_get_state", + f"{CONTROLLER_PATCH_PREFIX}.async_get_state", return_value=controller_state["controller"], ): await ws_client.send_json( @@ -410,7 +412,8 @@ async def test_node_metadata( "controller being inoperable or otherwise unavailable.)" ) assert result["manual"] == ( - "https://products.z-wavealliance.org/ProductManual/File?folder=&filename=MarketCertificationFiles/2479/ZP3111-5_R2_20170316.pdf" + "https://products.z-wavealliance.org/ProductManual/File?folder=&filename=" + "MarketCertificationFiles/2479/ZP3111-5_R2_20170316.pdf" ) assert not result["wakeup"] assert ( @@ -885,7 +888,7 @@ async def test_add_node( # Test FailedZWaveCommand is caught with patch( - "zwave_js_server.model.controller.Controller.async_begin_inclusion", + f"{CONTROLLER_PATCH_PREFIX}.async_begin_inclusion", side_effect=FailedZWaveCommand("failed_command", 1, "error message"), ): await ws_client.send_json( @@ -1155,7 +1158,7 @@ async def test_provision_smart_start_node( # Test FailedZWaveCommand is caught with patch( - "zwave_js_server.model.controller.Controller.async_provision_smart_start_node", + f"{CONTROLLER_PATCH_PREFIX}.async_provision_smart_start_node", side_effect=FailedZWaveCommand("failed_command", 1, "error message"), ): await ws_client.send_json( @@ -1163,7 +1166,9 @@ async def test_provision_smart_start_node( ID: 7, TYPE: "zwave_js/provision_smart_start_node", ENTRY_ID: entry.entry_id, - QR_CODE_STRING: "90testtesttesttesttesttesttesttesttesttesttesttesttest", + QR_CODE_STRING: ( + "90testtesttesttesttesttesttesttesttesttesttesttesttest" + ), } ) msg = await ws_client.receive_json() @@ -1259,7 +1264,7 @@ async def test_unprovision_smart_start_node( # Test FailedZWaveCommand is caught with patch( - "zwave_js_server.model.controller.Controller.async_unprovision_smart_start_node", + f"{CONTROLLER_PATCH_PREFIX}.async_unprovision_smart_start_node", side_effect=FailedZWaveCommand("failed_command", 1, "error message"), ): await ws_client.send_json( @@ -1332,7 +1337,7 @@ async def test_get_provisioning_entries( # Test FailedZWaveCommand is caught with patch( - "zwave_js_server.model.controller.Controller.async_get_provisioning_entries", + f"{CONTROLLER_PATCH_PREFIX}.async_get_provisioning_entries", side_effect=FailedZWaveCommand("failed_command", 1, "error message"), ): await ws_client.send_json( @@ -1432,7 +1437,9 @@ async def test_parse_qr_code_string( ID: 6, TYPE: "zwave_js/parse_qr_code_string", ENTRY_ID: entry.entry_id, - QR_CODE_STRING: "90testtesttesttesttesttesttesttesttesttesttesttesttest", + QR_CODE_STRING: ( + "90testtesttesttesttesttesttesttesttesttesttesttesttest" + ), } ) msg = await ws_client.receive_json() @@ -1497,7 +1504,9 @@ async def test_try_parse_dsk_from_qr_code_string( ID: 6, TYPE: "zwave_js/try_parse_dsk_from_qr_code_string", ENTRY_ID: entry.entry_id, - QR_CODE_STRING: "90testtesttesttesttesttesttesttesttesttesttesttesttest", + QR_CODE_STRING: ( + "90testtesttesttesttesttesttesttesttesttesttesttesttest" + ), } ) msg = await ws_client.receive_json() @@ -1572,7 +1581,7 @@ async def test_cancel_inclusion_exclusion( # Test FailedZWaveCommand is caught with patch( - "zwave_js_server.model.controller.Controller.async_stop_inclusion", + f"{CONTROLLER_PATCH_PREFIX}.async_stop_inclusion", side_effect=FailedZWaveCommand("failed_command", 1, "error message"), ): await ws_client.send_json( @@ -1590,7 +1599,7 @@ async def test_cancel_inclusion_exclusion( # Test FailedZWaveCommand is caught with patch( - "zwave_js_server.model.controller.Controller.async_stop_exclusion", + f"{CONTROLLER_PATCH_PREFIX}.async_stop_exclusion", side_effect=FailedZWaveCommand("failed_command", 1, "error message"), ): await ws_client.send_json( @@ -1709,7 +1718,7 @@ async def test_remove_node( # Test FailedZWaveCommand is caught with patch( - "zwave_js_server.model.controller.Controller.async_begin_exclusion", + f"{CONTROLLER_PATCH_PREFIX}.async_begin_exclusion", side_effect=FailedZWaveCommand("failed_command", 1, "error message"), ): await ws_client.send_json( @@ -2054,7 +2063,7 @@ async def test_replace_failed_node( # Test FailedZWaveCommand is caught with patch( - "zwave_js_server.model.controller.Controller.async_replace_failed_node", + f"{CONTROLLER_PATCH_PREFIX}.async_replace_failed_node", side_effect=FailedZWaveCommand("failed_command", 1, "error message"), ): await ws_client.send_json( @@ -2105,7 +2114,7 @@ async def test_remove_failed_node( # Test FailedZWaveCommand is caught with patch( - "zwave_js_server.model.controller.Controller.async_remove_failed_node", + f"{CONTROLLER_PATCH_PREFIX}.async_remove_failed_node", side_effect=FailedZWaveCommand("failed_command", 1, "error message"), ): await ws_client.send_json( @@ -2200,7 +2209,7 @@ async def test_begin_healing_network( # Test FailedZWaveCommand is caught with patch( - "zwave_js_server.model.controller.Controller.async_begin_healing_network", + f"{CONTROLLER_PATCH_PREFIX}.async_begin_healing_network", side_effect=FailedZWaveCommand("failed_command", 1, "error message"), ): await ws_client.send_json( @@ -2342,7 +2351,7 @@ async def test_stop_healing_network( # Test FailedZWaveCommand is caught with patch( - "zwave_js_server.model.controller.Controller.async_stop_healing_network", + f"{CONTROLLER_PATCH_PREFIX}.async_stop_healing_network", side_effect=FailedZWaveCommand("failed_command", 1, "error message"), ): await ws_client.send_json( @@ -2403,7 +2412,7 @@ async def test_heal_node( # Test FailedZWaveCommand is caught with patch( - "zwave_js_server.model.controller.Controller.async_heal_node", + f"{CONTROLLER_PATCH_PREFIX}.async_heal_node", side_effect=FailedZWaveCommand("failed_command", 1, "error message"), ): await ws_client.send_json( @@ -3279,7 +3288,7 @@ async def test_subscribe_log_updates( async def test_update_log_config( hass: HomeAssistant, client, integration, hass_ws_client: WebSocketGenerator ) -> None: - """Test that the update_log_config WS API call works and that schema validation works.""" + """Test that update_log_config WS API call and schema validation works.""" entry = integration ws_client = await hass_ws_client(hass) @@ -3841,7 +3850,7 @@ async def test_subscribe_firmware_update_status_initial_value( integration, hass_ws_client: WebSocketGenerator, ) -> None: - """Test subscribe_firmware_update_status websocket command with in progress update.""" + """Test subscribe_firmware_update_status WS command with in progress update.""" ws_client = await hass_ws_client(hass) device = get_device(hass, multisensor_6) @@ -4160,7 +4169,7 @@ async def test_is_any_ota_firmware_update_in_progress( # Test FailedZWaveCommand is caught with patch( - "zwave_js_server.model.controller.Controller.async_is_any_ota_firmware_update_in_progress", + f"{CONTROLLER_PATCH_PREFIX}.async_is_any_ota_firmware_update_in_progress", side_effect=FailedZWaveCommand("failed_command", 1, "error message"), ): await ws_client.send_json( diff --git a/tests/components/zwave_js/test_diagnostics.py b/tests/components/zwave_js/test_diagnostics.py index c7a711d10671..e7d7d9594bd7 100644 --- a/tests/components/zwave_js/test_diagnostics.py +++ b/tests/components/zwave_js/test_diagnostics.py @@ -122,7 +122,7 @@ async def test_device_diagnostics_missing_primary_value( integration, hass_client: ClientSessionGenerator, ) -> None: - """Test that the device diagnostics handles an entity with a missing primary value.""" + """Test that device diagnostics handles an entity with a missing primary value.""" dev_reg = async_get_dev_reg(hass) device = dev_reg.async_get_device({get_device_id(client.driver, multisensor_6)}) assert device diff --git a/tests/components/zwave_js/test_discovery.py b/tests/components/zwave_js/test_discovery.py index 1840e4d79800..1c4a69d32e39 100644 --- a/tests/components/zwave_js/test_discovery.py +++ b/tests/components/zwave_js/test_discovery.py @@ -1,6 +1,19 @@ -"""Test discovery of entities for device-specific schemas for the Z-Wave JS integration.""" +"""Test entity discovery for device-specific schemas for the Z-Wave JS integration.""" import pytest +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) from homeassistant.components.zwave_js.discovery import ( FirmwareVersionRange, ZWaveDiscoverySchema, @@ -9,6 +22,7 @@ from homeassistant.components.zwave_js.discovery import ( from homeassistant.components.zwave_js.discovery_data_template import ( DynamicCurrentTempClimateDataTemplate, ) +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_UNKNOWN, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -141,3 +155,141 @@ async def test_merten_507801_disabled_enitites( ) assert updated_entry != entry assert updated_entry.disabled is False + + +async def test_zooz_zen72( + hass: HomeAssistant, client, switch_zooz_zen72, integration +) -> None: + """Test that Zooz ZEN72 Indicators are discovered as number entities.""" + ent_reg = er.async_get(hass) + assert len(hass.states.async_entity_ids(NUMBER_DOMAIN)) == 1 + assert len(hass.states.async_entity_ids(BUTTON_DOMAIN)) == 2 # includes ping + entity_id = "number.z_wave_plus_700_series_dimmer_switch_indicator_value" + entry = ent_reg.async_get(entity_id) + assert entry + assert entry.entity_category == EntityCategory.CONFIG + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_UNKNOWN + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_VALUE: 5, + }, + 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"] == switch_zooz_zen72.node_id + assert args["valueId"] == { + "commandClass": 135, + "endpoint": 0, + "property": "value", + } + assert args["value"] == 5 + + client.async_send_command.reset_mock() + + entity_id = "button.z_wave_plus_700_series_dimmer_switch_identify" + entry = ent_reg.async_get(entity_id) + assert entry + assert entry.entity_category == EntityCategory.CONFIG + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + 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"] == switch_zooz_zen72.node_id + assert args["valueId"] == { + "commandClass": 135, + "endpoint": 0, + "property": "identify", + } + assert args["value"] is True + + +async def test_indicator_test( + hass: HomeAssistant, client, indicator_test, integration +) -> None: + """Test that Indicators are discovered properly. + + This test covers indicators that we don't already have device fixtures for. + """ + ent_reg = er.async_get(hass) + assert len(hass.states.async_entity_ids(NUMBER_DOMAIN)) == 0 + assert len(hass.states.async_entity_ids(BUTTON_DOMAIN)) == 1 # only ping + assert len(hass.states.async_entity_ids(BINARY_SENSOR_DOMAIN)) == 1 + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 2 # include node status + assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 1 + + entity_id = "binary_sensor.this_is_a_fake_device_binary_sensor" + entry = ent_reg.async_get(entity_id) + assert entry + assert entry.entity_category == EntityCategory.DIAGNOSTIC + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF + + client.async_send_command.reset_mock() + + entity_id = "sensor.this_is_a_fake_device_sensor" + entry = ent_reg.async_get(entity_id) + assert entry + assert entry.entity_category == EntityCategory.DIAGNOSTIC + state = hass.states.get(entity_id) + assert state + assert state.state == "0.0" + + client.async_send_command.reset_mock() + + entity_id = "switch.this_is_a_fake_device_switch" + entry = ent_reg.async_get(entity_id) + assert entry + assert entry.entity_category == EntityCategory.CONFIG + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + 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"] == indicator_test.node_id + assert args["valueId"] == { + "commandClass": 135, + "endpoint": 0, + "property": "Test", + "propertyKey": "Switch", + } + assert args["value"] is True + + client.async_send_command.reset_mock() + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + 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"] == indicator_test.node_id + assert args["valueId"] == { + "commandClass": 135, + "endpoint": 0, + "property": "Test", + "propertyKey": "Switch", + } + assert args["value"] is False diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index a6fbebf4b64c..45cf23a3f1e4 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -101,7 +101,7 @@ async def test_disabled_statistics(hass: HomeAssistant, client) -> None: async def test_noop_statistics(hass: HomeAssistant, client) -> None: - """Test that we don't make any statistics calls if user hasn't provided preference.""" + """Test that we don't make statistics calls if user hasn't set preference.""" entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) entry.add_to_hass(hass) @@ -1332,7 +1332,7 @@ async def test_node_model_change( async def test_disabled_node_status_entity_on_node_replaced( hass: HomeAssistant, zp3111_state, zp3111, client, integration ) -> None: - """Test that when a node replacement event is received the node status sensor is removed.""" + """Test when node replacement event is received, node status sensor is removed.""" node_status_entity = "sensor.4_in_1_sensor_node_status" state = hass.states.get(node_status_entity) assert state diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index d38466539e92..6056d6391811 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -46,7 +46,6 @@ from .common import ( ENERGY_SENSOR, HUMIDITY_SENSOR, ID_LOCK_CONFIG_PARAMETER_SENSOR, - INDICATOR_SENSOR, METER_ENERGY_SENSOR, NOTIFICATION_MOTION_SENSOR, POWER_SENSOR, @@ -218,18 +217,6 @@ async def test_disabled_notification_sensor( assert state.state == STATE_UNKNOWN -async def test_disabled_indcator_sensor( - hass: HomeAssistant, climate_radio_thermostat_ct100_plus, integration -) -> None: - """Test sensor is created from Indicator CC and is disabled.""" - ent_reg = er.async_get(hass) - entity_entry = ent_reg.async_get(INDICATOR_SENSOR) - - assert entity_entry - assert entity_entry.disabled - assert entity_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION - - async def test_config_parameter_sensor( hass: HomeAssistant, lock_id_lock_as_id150, integration ) -> None: From e97b33194209ef1662d6449b55ebd2a241f1f5b3 Mon Sep 17 00:00:00 2001 From: MarkGodwin <10632972+MarkGodwin@users.noreply.github.com> Date: Wed, 24 May 2023 14:12:51 +0100 Subject: [PATCH 519/822] Add gateway WAN/LAN port connectivity entities to TP-Link Omada (#91905) * Add gateway WAN/LAN port status entities * Fix copy pasta comments * Add omada binary_sensor to coveragerc --- .coveragerc | 1 + .../components/tplink_omada/__init__.py | 2 +- .../components/tplink_omada/binary_sensor.py | 120 ++++++++++++++++++ .../components/tplink_omada/controller.py | 44 ++++++- 4 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/tplink_omada/binary_sensor.py diff --git a/.coveragerc b/.coveragerc index 792e0c3785ca..30df1694a25c 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1297,6 +1297,7 @@ omit = homeassistant/components/touchline/climate.py homeassistant/components/tplink_lte/* homeassistant/components/tplink_omada/__init__.py + homeassistant/components/tplink_omada/binary_sensor.py homeassistant/components/tplink_omada/controller.py homeassistant/components/tplink_omada/coordinator.py homeassistant/components/tplink_omada/entity.py diff --git a/homeassistant/components/tplink_omada/__init__.py b/homeassistant/components/tplink_omada/__init__.py index 824ea8df4239..1367f8757af6 100644 --- a/homeassistant/components/tplink_omada/__init__.py +++ b/homeassistant/components/tplink_omada/__init__.py @@ -18,7 +18,7 @@ from .config_flow import CONF_SITE, create_omada_client from .const import DOMAIN from .controller import OmadaSiteController -PLATFORMS: list[Platform] = [Platform.SWITCH, Platform.UPDATE] +PLATFORMS: list[Platform] = [Platform.SWITCH, Platform.UPDATE, Platform.BINARY_SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/tplink_omada/binary_sensor.py b/homeassistant/components/tplink_omada/binary_sensor.py new file mode 100644 index 000000000000..caaae3465b77 --- /dev/null +++ b/homeassistant/components/tplink_omada/binary_sensor.py @@ -0,0 +1,120 @@ +"""Support for TPLink Omada binary sensors.""" +from __future__ import annotations + +from collections.abc import Callable, Generator + +from attr import dataclass +from tplink_omada_client.definitions import GatewayPortMode, LinkStatus +from tplink_omada_client.devices import OmadaDevice, OmadaGateway, OmadaGatewayPort + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .controller import OmadaGatewayCoordinator, OmadaSiteController +from .entity import OmadaDeviceEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up binary sensors.""" + controller: OmadaSiteController = hass.data[DOMAIN][config_entry.entry_id] + omada_client = controller.omada_client + + gateway_coordinator = await controller.get_gateway_coordinator() + if not gateway_coordinator: + return + + gateway = await omada_client.get_gateway(gateway_coordinator.mac) + + async_add_entities( + get_gateway_port_status_sensors(gateway, hass, gateway_coordinator) + ) + + await gateway_coordinator.async_request_refresh() + + +def get_gateway_port_status_sensors( + gateway: OmadaGateway, hass: HomeAssistant, coordinator: OmadaGatewayCoordinator +) -> Generator[BinarySensorEntity, None, None]: + """Generate binary sensors for gateway ports.""" + for port in gateway.port_status: + if port.mode == GatewayPortMode.WAN: + yield OmadaGatewayPortBinarySensor( + coordinator, + gateway, + GatewayPortBinarySensorConfig( + port_number=port.port_number, + id_suffix="wan_link", + name_suffix="Internet Link", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + update_func=lambda p: p.wan_connected, + ), + ) + if port.mode == GatewayPortMode.LAN: + yield OmadaGatewayPortBinarySensor( + coordinator, + gateway, + GatewayPortBinarySensorConfig( + port_number=port.port_number, + id_suffix="lan_status", + name_suffix="LAN Status", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + update_func=lambda p: p.link_status == LinkStatus.LINK_UP, + ), + ) + + +@dataclass +class GatewayPortBinarySensorConfig: + """Config for a binary status derived from a gateway port.""" + + port_number: int + id_suffix: str + name_suffix: str + device_class: BinarySensorDeviceClass + update_func: Callable[[OmadaGatewayPort], bool] + + +class OmadaGatewayPortBinarySensor(OmadaDeviceEntity[OmadaGateway], BinarySensorEntity): + """Binary status of a property on an internet gateway.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: OmadaGatewayCoordinator, + device: OmadaDevice, + config: GatewayPortBinarySensorConfig, + ) -> None: + """Initialize the gateway port binary sensor.""" + super().__init__(coordinator, device) + self._config = config + self._attr_unique_id = f"{device.mac}_{config.port_number}_{config.id_suffix}" + self._attr_device_class = config.device_class + + self._attr_name = f"Port {config.port_number} {config.name_suffix}" + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + gateway = self.coordinator.data[self.device.mac] + + port = next( + p for p in gateway.port_status if p.port_number == self._config.port_number + ) + if port: + self._attr_is_on = self._config.update_func(port) + self._attr_available = True + else: + self._attr_available = False + + self.async_write_ha_state() diff --git a/homeassistant/components/tplink_omada/controller.py b/homeassistant/components/tplink_omada/controller.py index 508a8b914da9..194f18ae9bfc 100644 --- a/homeassistant/components/tplink_omada/controller.py +++ b/homeassistant/components/tplink_omada/controller.py @@ -1,6 +1,10 @@ """Controller for sharing Omada API coordinators between platforms.""" -from tplink_omada_client.devices import OmadaSwitch, OmadaSwitchPortDetails +from tplink_omada_client.devices import ( + OmadaGateway, + OmadaSwitch, + OmadaSwitchPortDetails, +) from tplink_omada_client.omadasiteclient import OmadaSiteClient from homeassistant.core import HomeAssistant @@ -8,6 +12,7 @@ from homeassistant.core import HomeAssistant from .coordinator import OmadaCoordinator POLL_SWITCH_PORT = 300 +POLL_GATEWAY = 300 class OmadaSwitchPortCoordinator(OmadaCoordinator[OmadaSwitchPortDetails]): @@ -31,9 +36,31 @@ class OmadaSwitchPortCoordinator(OmadaCoordinator[OmadaSwitchPortDetails]): return {p.port_id: p for p in ports} +class OmadaGatewayCoordinator(OmadaCoordinator[OmadaGateway]): + """Coordinator for getting details about the site's gateway.""" + + def __init__( + self, + hass: HomeAssistant, + omada_client: OmadaSiteClient, + mac: str, + ) -> None: + """Initialize my coordinator.""" + super().__init__(hass, omada_client, "Gateway", POLL_GATEWAY) + self.mac = mac + + async def poll_update(self) -> dict[str, OmadaGateway]: + """Poll a the gateway's current state.""" + gateway = await self.omada_client.get_gateway(self.mac) + return {self.mac: gateway} + + class OmadaSiteController: """Controller for the Omada SDN site.""" + _gateway_coordinator: OmadaGatewayCoordinator | None = None + _initialized_gateway_coordinator = False + def __init__(self, hass: HomeAssistant, omada_client: OmadaSiteClient) -> None: """Create the controller.""" self._hass = hass @@ -56,3 +83,18 @@ class OmadaSiteController: ) return self._switch_port_coordinators[switch.mac] + + async def get_gateway_coordinator(self) -> OmadaGatewayCoordinator | None: + """Get coordinator for site's gateway, or None if there is no gateway.""" + if not self._initialized_gateway_coordinator: + self._initialized_gateway_coordinator = True + devices = await self._omada_client.get_devices() + gateway = next((d for d in devices if d.type == "gateway"), None) + if not gateway: + return None + + self._gateway_coordinator = OmadaGatewayCoordinator( + self._hass, self._omada_client, gateway.mac + ) + + return self._gateway_coordinator From 16780bf4c2e797bb2ff51621eb1f3984165ed65b Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 24 May 2023 15:16:35 +0200 Subject: [PATCH 520/822] Prevent firmware update error when internet blocked for Reolink camera (#91738) * Prevent firmware update error when internet blocked * fix styling * switch back to async_config_entry_first_refresh * fix still raising when starting * missing return * Catch CancelledError --- homeassistant/components/reolink/__init__.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 76c0963e2c06..a5050d3c436d 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -75,6 +75,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, host.stop) ) + starting = True + async def async_device_config_update() -> None: """Update the host state cache and renew the ONVIF-subscription.""" async with async_timeout.timeout(host.api.timeout): @@ -96,9 +98,19 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b async with async_timeout.timeout(host.api.timeout): try: return await host.api.check_new_firmware() - except ReolinkError as err: + except (ReolinkError, asyncio.exceptions.CancelledError) as err: + if starting: + _LOGGER.debug( + "Error checking Reolink firmware update at startup " + "from %s, possibly internet access is blocked", + host.api.nvr_name, + ) + return False + raise UpdateFailed( - f"Error checking Reolink firmware update {host.api.nvr_name}" + f"Error checking Reolink firmware update from {host.api.nvr_name}, " + "if the camera is blocked from accessing the internet, " + "disable the update entity" ) from err device_coordinator = DataUpdateCoordinator( @@ -120,7 +132,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b # If camera WAN blocked, firmware check fails, do not prevent setup await asyncio.gather( device_coordinator.async_config_entry_first_refresh(), - firmware_coordinator.async_refresh(), + firmware_coordinator.async_config_entry_first_refresh(), ) except ConfigEntryNotReady: await host.stop() @@ -138,6 +150,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b config_entry.add_update_listener(entry_update_listener) ) + starting = False return True From f8fdeeabc2e36bd00ac46e09a4555778f7c2f63a Mon Sep 17 00:00:00 2001 From: Vova-SH <60854798+Vova-SH@users.noreply.github.com> Date: Wed, 24 May 2023 21:07:22 +0700 Subject: [PATCH 521/822] Add resize_keyboard and one_time_keyboard attributes to telegram bot service (#91613) * Add new params for telegram bot Add resize_keyboard and one_time_keyboard attributes * Apply review changes * Update homeassistant/components/telegram_bot/__init__.py * Update __init__.py --------- Co-authored-by: Erik Montnemery --- homeassistant/components/telegram_bot/__init__.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index a453a4611bf6..0bb1efc6b124 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -62,6 +62,8 @@ ATTR_FILE = "file" ATTR_FROM_FIRST = "from_first" ATTR_FROM_LAST = "from_last" ATTR_KEYBOARD = "keyboard" +ATTR_RESIZE_KEYBOARD = "resize_keyboard" +ATTR_ONE_TIME_KEYBOARD = "one_time_keyboard" ATTR_KEYBOARD_INLINE = "inline_keyboard" ATTR_MESSAGEID = "message_id" ATTR_MSG = "message" @@ -157,6 +159,8 @@ BASE_SERVICE_SCHEMA = vol.Schema( vol.Optional(ATTR_PARSER): cv.string, vol.Optional(ATTR_DISABLE_NOTIF): cv.boolean, vol.Optional(ATTR_DISABLE_WEB_PREV): cv.boolean, + vol.Optional(ATTR_RESIZE_KEYBOARD): cv.boolean, + vol.Optional(ATTR_ONE_TIME_KEYBOARD): cv.boolean, vol.Optional(ATTR_KEYBOARD): vol.All(cv.ensure_list, [cv.string]), vol.Optional(ATTR_KEYBOARD_INLINE): cv.ensure_list, vol.Optional(ATTR_TIMEOUT): cv.positive_int, @@ -581,7 +585,13 @@ class TelegramNotificationService: keys = keys if isinstance(keys, list) else [keys] if keys: params[ATTR_REPLYMARKUP] = ReplyKeyboardMarkup( - [[key.strip() for key in row.split(",")] for row in keys] + [[key.strip() for key in row.split(",")] for row in keys], + resize_keyboard=data[ATTR_RESIZE_KEYBOARD] + if ATTR_RESIZE_KEYBOARD in data + else False, + one_time_keyboard=data[ATTR_ONE_TIME_KEYBOARD] + if ATTR_ONE_TIME_KEYBOARD in data + else False, ) else: params[ATTR_REPLYMARKUP] = ReplyKeyboardRemove(True) From d2d21b41589894dca4603691e0972fb50a9b368e Mon Sep 17 00:00:00 2001 From: Martin Malina Date: Wed, 24 May 2023 16:07:59 +0200 Subject: [PATCH 522/822] Show current day in met weather forecast (#91891) Version 0.10.0 of pyMetno added the option to defined range_start when calling get_forecast: https://github.com/Danielhiversen/pyMetno/pull/35/files So let's use that to include today in the forecast. --- homeassistant/components/met/__init__.py | 2 +- homeassistant/components/met/manifest.json | 2 +- homeassistant/components/norway_air/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/met/__init__.py b/homeassistant/components/met/__init__.py index c676f15336ec..32b095230d90 100644 --- a/homeassistant/components/met/__init__.py +++ b/homeassistant/components/met/__init__.py @@ -179,6 +179,6 @@ class MetWeatherData: raise CannotConnect() self.current_weather_data = self._weather_data.get_current_weather() time_zone = dt_util.DEFAULT_TIME_ZONE - self.daily_forecast = self._weather_data.get_forecast(time_zone, False) + self.daily_forecast = self._weather_data.get_forecast(time_zone, False, 0) self.hourly_forecast = self._weather_data.get_forecast(time_zone, True) return self diff --git a/homeassistant/components/met/manifest.json b/homeassistant/components/met/manifest.json index 5e00e13b808e..32d37e9b4ffc 100644 --- a/homeassistant/components/met/manifest.json +++ b/homeassistant/components/met/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/met", "iot_class": "cloud_polling", "loggers": ["metno"], - "requirements": ["pyMetno==0.9.0"] + "requirements": ["pyMetno==0.10.0"] } diff --git a/homeassistant/components/norway_air/manifest.json b/homeassistant/components/norway_air/manifest.json index 12cd22149d32..d04e07f02146 100644 --- a/homeassistant/components/norway_air/manifest.json +++ b/homeassistant/components/norway_air/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/norway_air", "iot_class": "cloud_polling", "loggers": ["metno"], - "requirements": ["pyMetno==0.9.0"] + "requirements": ["pyMetno==0.10.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6173d502c817..62a047a525c6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1472,7 +1472,7 @@ pyMetEireann==2021.8.0 # homeassistant.components.met # homeassistant.components.norway_air -pyMetno==0.9.0 +pyMetno==0.10.0 # homeassistant.components.rfxtrx pyRFXtrx==0.30.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8af9d775bebe..34dd029a7cf7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1099,7 +1099,7 @@ pyMetEireann==2021.8.0 # homeassistant.components.met # homeassistant.components.norway_air -pyMetno==0.9.0 +pyMetno==0.10.0 # homeassistant.components.rfxtrx pyRFXtrx==0.30.1 From 08c8c22fac11b750f75d916d86c7ddf1c3b00c21 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 24 May 2023 11:21:44 -0500 Subject: [PATCH 523/822] Fix non threadsafe call in xiaomi_aqara again (#93461) Fix non threadsafe call in xiaomi_aqara The original fix in #93405 was ineffective and only moved the problem fixes #93400 --- homeassistant/components/xiaomi_aqara/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/xiaomi_aqara/__init__.py b/homeassistant/components/xiaomi_aqara/__init__.py index f51b1a2972ba..16c3fa547314 100644 --- a/homeassistant/components/xiaomi_aqara/__init__.py +++ b/homeassistant/components/xiaomi_aqara/__init__.py @@ -354,7 +354,7 @@ class XiaomiDevice(Entity): def push_data(self, data: dict[str, Any], raw_data: dict[Any, Any]) -> None: """Push from Hub running in another thread.""" - self.hass.loop.call_soon(self.async_push_data, data, raw_data) + self.hass.loop.call_soon_threadsafe(self.async_push_data, data, raw_data) @callback def async_push_data(self, data: dict[str, Any], raw_data: dict[Any, Any]) -> None: From 3e84c374f4bca41e8829221761f2dc9999ac337a Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Wed, 24 May 2023 12:51:15 -0400 Subject: [PATCH 524/822] Improve zwave.cover tests (#93458) * Improve zwave.cover tests * Update conftest.py --- tests/components/zwave_js/conftest.py | 2 +- tests/components/zwave_js/test_cover.py | 121 +++++++++++++++--------- 2 files changed, 76 insertions(+), 47 deletions(-) diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index d071d9379157..5b2417f258b5 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -946,7 +946,7 @@ def motorized_barrier_cover_fixture(client, gdc_zw062_state): @pytest.fixture(name="iblinds_v2") -def iblinds_cover_fixture(client, iblinds_v2_state): +def iblinds_v2_cover_fixture(client, iblinds_v2_state): """Mock an iBlinds v2.0 window cover node.""" node = Node(client, copy.deepcopy(iblinds_v2_state)) client.driver.controller.nodes[node.node_id] = node diff --git a/tests/components/zwave_js/test_cover.py b/tests/components/zwave_js/test_cover.py index f1e936659380..66992a5f4d05 100644 --- a/tests/components/zwave_js/test_cover.py +++ b/tests/components/zwave_js/test_cover.py @@ -10,14 +10,22 @@ from zwave_js_server.model.node import Node from homeassistant.components.cover import ( ATTR_CURRENT_POSITION, ATTR_CURRENT_TILT_POSITION, + ATTR_POSITION, + ATTR_TILT_POSITION, DOMAIN, SERVICE_CLOSE_COVER, + SERVICE_CLOSE_COVER_TILT, SERVICE_OPEN_COVER, + SERVICE_OPEN_COVER_TILT, + SERVICE_SET_COVER_POSITION, + SERVICE_SET_COVER_TILT_POSITION, + SERVICE_STOP_COVER, CoverDeviceClass, ) from homeassistant.components.zwave_js.helpers import ZwaveValueMatcher from homeassistant.const import ( ATTR_DEVICE_CLASS, + ATTR_ENTITY_ID, STATE_CLOSED, STATE_CLOSING, STATE_OPEN, @@ -46,14 +54,14 @@ async def test_window_cover( assert state assert state.attributes[ATTR_DEVICE_CLASS] == CoverDeviceClass.WINDOW - assert state.state == "closed" + assert state.state == STATE_CLOSED assert state.attributes[ATTR_CURRENT_POSITION] == 0 # Test setting position await hass.services.async_call( - "cover", - "set_cover_position", - {"entity_id": WINDOW_COVER_ENTITY, "position": 50}, + DOMAIN, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: WINDOW_COVER_ENTITY, ATTR_POSITION: 50}, blocking=True, ) @@ -72,9 +80,9 @@ async def test_window_cover( # Test setting position await hass.services.async_call( - "cover", - "set_cover_position", - {"entity_id": WINDOW_COVER_ENTITY, "position": 0}, + DOMAIN, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: WINDOW_COVER_ENTITY, ATTR_POSITION: 0}, blocking=True, ) @@ -93,9 +101,9 @@ async def test_window_cover( # Test opening await hass.services.async_call( - "cover", - "open_cover", - {"entity_id": WINDOW_COVER_ENTITY}, + DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: WINDOW_COVER_ENTITY}, blocking=True, ) @@ -113,9 +121,9 @@ async def test_window_cover( client.async_send_command.reset_mock() # Test stop after opening await hass.services.async_call( - "cover", - "stop_cover", - {"entity_id": WINDOW_COVER_ENTITY}, + DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: WINDOW_COVER_ENTITY}, blocking=True, ) @@ -152,13 +160,13 @@ async def test_window_cover( client.async_send_command.reset_mock() state = hass.states.get(WINDOW_COVER_ENTITY) - assert state.state == "open" + assert state.state == STATE_OPEN # Test closing await hass.services.async_call( - "cover", - "close_cover", - {"entity_id": WINDOW_COVER_ENTITY}, + DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: WINDOW_COVER_ENTITY}, blocking=True, ) assert len(client.async_send_command.call_args_list) == 1 @@ -176,9 +184,9 @@ async def test_window_cover( # Test stop after closing await hass.services.async_call( - "cover", - "stop_cover", - {"entity_id": WINDOW_COVER_ENTITY}, + DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: WINDOW_COVER_ENTITY}, blocking=True, ) @@ -215,10 +223,10 @@ async def test_window_cover( node.receive_event(event) state = hass.states.get(WINDOW_COVER_ENTITY) - assert state.state == "closed" + assert state.state == STATE_CLOSED -async def test_fibaro_FGR222_shutter_cover( +async def test_fibaro_fgr222_shutter_cover( hass: HomeAssistant, client, fibaro_fgr222_shutter, integration ) -> None: """Test tilt function of the Fibaro Shutter devices.""" @@ -226,14 +234,14 @@ async def test_fibaro_FGR222_shutter_cover( assert state assert state.attributes[ATTR_DEVICE_CLASS] == CoverDeviceClass.SHUTTER - assert state.state == "open" + assert state.state == STATE_OPEN assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 0 # Test opening tilts await hass.services.async_call( - "cover", - "open_cover_tilt", - {"entity_id": FIBARO_SHUTTER_COVER_ENTITY}, + DOMAIN, + SERVICE_OPEN_COVER_TILT, + {ATTR_ENTITY_ID: FIBARO_SHUTTER_COVER_ENTITY}, blocking=True, ) @@ -252,9 +260,9 @@ async def test_fibaro_FGR222_shutter_cover( client.async_send_command.reset_mock() # Test closing tilts await hass.services.async_call( - "cover", - "close_cover_tilt", - {"entity_id": FIBARO_SHUTTER_COVER_ENTITY}, + DOMAIN, + SERVICE_CLOSE_COVER_TILT, + {ATTR_ENTITY_ID: FIBARO_SHUTTER_COVER_ENTITY}, blocking=True, ) @@ -270,6 +278,27 @@ async def test_fibaro_FGR222_shutter_cover( } 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_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"] == 42 + assert args["valueId"] == { + "endpoint": 0, + "commandClass": 145, + "property": "fibaro", + "propertyKey": "venetianBlindsTilt", + } + assert args["value"] == 12 + # Test some tilt event = Event( type="value updated", @@ -306,14 +335,14 @@ async def test_aeotec_nano_shutter_cover( assert state assert state.attributes[ATTR_DEVICE_CLASS] == CoverDeviceClass.WINDOW - assert state.state == "closed" + assert state.state == STATE_CLOSED assert state.attributes[ATTR_CURRENT_POSITION] == 0 # Test opening await hass.services.async_call( - "cover", - "open_cover", - {"entity_id": AEOTEC_SHUTTER_COVER_ENTITY}, + DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: AEOTEC_SHUTTER_COVER_ENTITY}, blocking=True, ) @@ -331,9 +360,9 @@ async def test_aeotec_nano_shutter_cover( client.async_send_command.reset_mock() # Test stop after opening await hass.services.async_call( - "cover", - "stop_cover", - {"entity_id": AEOTEC_SHUTTER_COVER_ENTITY}, + DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: AEOTEC_SHUTTER_COVER_ENTITY}, blocking=True, ) @@ -371,13 +400,13 @@ async def test_aeotec_nano_shutter_cover( client.async_send_command.reset_mock() state = hass.states.get(AEOTEC_SHUTTER_COVER_ENTITY) - assert state.state == "open" + assert state.state == STATE_OPEN # Test closing await hass.services.async_call( - "cover", - "close_cover", - {"entity_id": AEOTEC_SHUTTER_COVER_ENTITY}, + DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: AEOTEC_SHUTTER_COVER_ENTITY}, blocking=True, ) assert len(client.async_send_command.call_args_list) == 1 @@ -395,9 +424,9 @@ async def test_aeotec_nano_shutter_cover( # Test stop after closing await hass.services.async_call( - "cover", - "stop_cover", - {"entity_id": AEOTEC_SHUTTER_COVER_ENTITY}, + DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: AEOTEC_SHUTTER_COVER_ENTITY}, blocking=True, ) @@ -447,7 +476,7 @@ async def test_motor_barrier_cover( # Test open await hass.services.async_call( - DOMAIN, SERVICE_OPEN_COVER, {"entity_id": GDC_COVER_ENTITY}, blocking=True + DOMAIN, SERVICE_OPEN_COVER, {ATTR_ENTITY_ID: GDC_COVER_ENTITY}, blocking=True ) assert len(client.async_send_command.call_args_list) == 1 @@ -469,7 +498,7 @@ async def test_motor_barrier_cover( # Test close await hass.services.async_call( - DOMAIN, SERVICE_CLOSE_COVER, {"entity_id": GDC_COVER_ENTITY}, blocking=True + DOMAIN, SERVICE_CLOSE_COVER, {ATTR_ENTITY_ID: GDC_COVER_ENTITY}, blocking=True ) assert len(client.async_send_command.call_args_list) == 1 @@ -631,7 +660,7 @@ async def test_motor_barrier_cover_no_primary_value( assert ATTR_CURRENT_POSITION not in state.attributes -async def test_fibaro_FGR222_shutter_cover_no_tilt( +async def test_fibaro_fgr222_shutter_cover_no_tilt( hass: HomeAssistant, client, fibaro_fgr222_shutter_state, integration ) -> None: """Test tilt function of the Fibaro Shutter devices with tilt value is None.""" From da5ad4a17d18fc9f4a9d459c752148e35f70df0d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 24 May 2023 13:17:11 -0500 Subject: [PATCH 525/822] Update binary_sensor tests to avoid patching utcnow (#93474) --- tests/components/binary_sensor/test_device_condition.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/tests/components/binary_sensor/test_device_condition.py b/tests/components/binary_sensor/test_device_condition.py index d476d965144c..ecc5f7bc8b03 100644 --- a/tests/components/binary_sensor/test_device_condition.py +++ b/tests/components/binary_sensor/test_device_condition.py @@ -1,7 +1,7 @@ """The test for binary_sensor device automation.""" from datetime import timedelta -from unittest.mock import patch +from freezegun import freeze_time import pytest import homeassistant.components.automation as automation @@ -285,8 +285,7 @@ async def test_if_fires_on_for_condition( sensor1 = platform.ENTITIES["battery"] - with patch("homeassistant.core.dt_util.utcnow") as mock_utcnow: - mock_utcnow.return_value = point1 + with freeze_time(point1) as time_freeze: assert await async_setup_component( hass, automation.DOMAIN, @@ -324,7 +323,7 @@ async def test_if_fires_on_for_condition( assert len(calls) == 0 # Time travel 10 secs into the future - mock_utcnow.return_value = point2 + time_freeze.move_to(point2) hass.bus.async_fire("test_event1") await hass.async_block_till_done() assert len(calls) == 0 @@ -335,7 +334,7 @@ async def test_if_fires_on_for_condition( assert len(calls) == 0 # Time travel 20 secs into the future - mock_utcnow.return_value = point3 + time_freeze.move_to(point3) hass.bus.async_fire("test_event1") await hass.async_block_till_done() assert len(calls) == 1 From e80579e52f31d4e1d407962292b3b1d8c9b3f0d6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 24 May 2023 13:17:28 -0500 Subject: [PATCH 526/822] Update generic_thermostat tests to avoid patching utcnow (#93479) --- .../generic_thermostat/test_climate.py | 25 ++++++------------- 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/tests/components/generic_thermostat/test_climate.py b/tests/components/generic_thermostat/test_climate.py index 397096952d59..99720ef85270 100644 --- a/tests/components/generic_thermostat/test_climate.py +++ b/tests/components/generic_thermostat/test_climate.py @@ -2,6 +2,7 @@ import datetime from unittest.mock import patch +from freezegun import freeze_time import pytest import voluptuous as vol @@ -786,9 +787,7 @@ async def test_temp_change_ac_trigger_on_long_enough( ) -> None: """Test if temperature change turn ac on.""" fake_changed = datetime.datetime(1970, 11, 11, 11, 11, 11, tzinfo=dt_util.UTC) - with patch( - "homeassistant.helpers.condition.dt_util.utcnow", return_value=fake_changed - ): + with freeze_time(fake_changed): calls = _setup_switch(hass, False) await common.async_set_temperature(hass, 25) _setup_sensor(hass, 30) @@ -816,9 +815,7 @@ async def test_temp_change_ac_trigger_off_long_enough( ) -> None: """Test if temperature change turn ac on.""" fake_changed = datetime.datetime(1970, 11, 11, 11, 11, 11, tzinfo=dt_util.UTC) - with patch( - "homeassistant.helpers.condition.dt_util.utcnow", return_value=fake_changed - ): + with freeze_time(fake_changed): calls = _setup_switch(hass, True) await common.async_set_temperature(hass, 30) _setup_sensor(hass, 25) @@ -904,9 +901,7 @@ async def test_temp_change_ac_trigger_on_long_enough_2( ) -> None: """Test if temperature change turn ac on.""" fake_changed = datetime.datetime(1970, 11, 11, 11, 11, 11, tzinfo=dt_util.UTC) - with patch( - "homeassistant.helpers.condition.dt_util.utcnow", return_value=fake_changed - ): + with freeze_time(fake_changed): calls = _setup_switch(hass, False) await common.async_set_temperature(hass, 25) _setup_sensor(hass, 30) @@ -934,9 +929,7 @@ async def test_temp_change_ac_trigger_off_long_enough_2( ) -> None: """Test if temperature change turn ac on.""" fake_changed = datetime.datetime(1970, 11, 11, 11, 11, 11, tzinfo=dt_util.UTC) - with patch( - "homeassistant.helpers.condition.dt_util.utcnow", return_value=fake_changed - ): + with freeze_time(fake_changed): calls = _setup_switch(hass, True) await common.async_set_temperature(hass, 30) _setup_sensor(hass, 25) @@ -1032,9 +1025,7 @@ async def test_temp_change_heater_trigger_on_long_enough( ) -> None: """Test if temperature change turn heater on after min cycle.""" fake_changed = datetime.datetime(1970, 11, 11, 11, 11, 11, tzinfo=dt_util.UTC) - with patch( - "homeassistant.helpers.condition.dt_util.utcnow", return_value=fake_changed - ): + with freeze_time(fake_changed): calls = _setup_switch(hass, False) await common.async_set_temperature(hass, 30) _setup_sensor(hass, 25) @@ -1051,9 +1042,7 @@ async def test_temp_change_heater_trigger_off_long_enough( ) -> None: """Test if temperature change turn heater off after min cycle.""" fake_changed = datetime.datetime(1970, 11, 11, 11, 11, 11, tzinfo=dt_util.UTC) - with patch( - "homeassistant.helpers.condition.dt_util.utcnow", return_value=fake_changed - ): + with freeze_time(fake_changed): calls = _setup_switch(hass, True) await common.async_set_temperature(hass, 25) _setup_sensor(hass, 30) From 8dd9482bb5e06b5abaf674f78f8aa74f5b6bccb5 Mon Sep 17 00:00:00 2001 From: Luke Date: Wed, 24 May 2023 14:31:02 -0400 Subject: [PATCH 527/822] Add Roborock Consumable sensors (#91972) * add sensors to roborock * add value_fn typing * fixed strings * Update homeassistant/components/roborock/sensor.py Co-authored-by: Franck Nijhof * change to work time instead of time left * added time left back * fixed tests * made diagnostic --------- Co-authored-by: Franck Nijhof --- homeassistant/components/roborock/const.py | 2 +- homeassistant/components/roborock/sensor.py | 122 ++++++++++++++++++ .../components/roborock/strings.json | 14 ++ tests/components/roborock/conftest.py | 7 +- tests/components/roborock/test_sensor.py | 29 +++++ 5 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/roborock/sensor.py create mode 100644 tests/components/roborock/test_sensor.py diff --git a/homeassistant/components/roborock/const.py b/homeassistant/components/roborock/const.py index 61a9a70dd207..5fb0f888eadf 100644 --- a/homeassistant/components/roborock/const.py +++ b/homeassistant/components/roborock/const.py @@ -6,4 +6,4 @@ CONF_ENTRY_CODE = "code" CONF_BASE_URL = "base_url" CONF_USER_DATA = "user_data" -PLATFORMS = [Platform.VACUUM, Platform.SELECT] +PLATFORMS = [Platform.VACUUM, Platform.SELECT, Platform.SENSOR] diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py new file mode 100644 index 000000000000..5841641d6cba --- /dev/null +++ b/homeassistant/components/roborock/sensor.py @@ -0,0 +1,122 @@ +"""Support for Roborock sensors.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from roborock.typing import DeviceProp + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory, UnitOfTime +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.util import slugify + +from .const import DOMAIN +from .coordinator import RoborockDataUpdateCoordinator +from .device import RoborockCoordinatedEntity +from .models import RoborockHassDeviceInfo + + +@dataclass +class RoborockSensorDescriptionMixin: + """A class that describes sensor entities.""" + + value_fn: Callable[[DeviceProp], int] + + +@dataclass +class RoborockSensorDescription( + SensorEntityDescription, RoborockSensorDescriptionMixin +): + """A class that describes Roborock sensors.""" + + +CONSUMABLE_SENSORS = [ + RoborockSensorDescription( + native_unit_of_measurement=UnitOfTime.SECONDS, + key="main_brush_time_left", + icon="mdi:brush", + device_class=SensorDeviceClass.DURATION, + translation_key="main_brush_time_left", + value_fn=lambda data: data.consumable.main_brush_time_left, + entity_category=EntityCategory.DIAGNOSTIC, + ), + RoborockSensorDescription( + native_unit_of_measurement=UnitOfTime.SECONDS, + key="side_brush_time_left", + icon="mdi:brush", + device_class=SensorDeviceClass.DURATION, + translation_key="side_brush_time_left", + value_fn=lambda data: data.consumable.side_brush_time_left, + entity_category=EntityCategory.DIAGNOSTIC, + ), + RoborockSensorDescription( + native_unit_of_measurement=UnitOfTime.SECONDS, + key="filter_time_left", + icon="mdi:air-filter", + device_class=SensorDeviceClass.DURATION, + translation_key="filter_time_left", + value_fn=lambda data: data.consumable.filter_time_left, + entity_category=EntityCategory.DIAGNOSTIC, + ), + RoborockSensorDescription( + native_unit_of_measurement=UnitOfTime.SECONDS, + key="sensor_time_left", + icon="mdi:eye-outline", + device_class=SensorDeviceClass.DURATION, + translation_key="sensor_time_left", + value_fn=lambda data: data.consumable.sensor_time_left, + entity_category=EntityCategory.DIAGNOSTIC, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Roborock vacuum sensors.""" + coordinator: RoborockDataUpdateCoordinator = hass.data[DOMAIN][ + config_entry.entry_id + ] + + async_add_entities( + RoborockSensorEntity( + f"{description.key}_{slugify(device_id)}", + device_info, + coordinator, + description, + ) + for device_id, device_info in coordinator.devices_info.items() + for description in CONSUMABLE_SENSORS + ) + + +class RoborockSensorEntity(RoborockCoordinatedEntity, SensorEntity): + """Representation of a Roborock sensor.""" + + entity_description: RoborockSensorDescription + + def __init__( + self, + unique_id: str, + device_info: RoborockHassDeviceInfo, + coordinator: RoborockDataUpdateCoordinator, + description: RoborockSensorDescription, + ) -> None: + """Initialize the entity.""" + super().__init__(unique_id, device_info, coordinator) + self.entity_description = description + + @property + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" + return self.entity_description.value_fn(self.coordinator.data[self._device_id]) diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 64c9d268e56c..7e8e2ba37a0f 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -27,6 +27,20 @@ } }, "entity": { + "sensor": { + "main_brush_time_left": { + "name": "Main brush time left" + }, + "side_brush_time_left": { + "name": "Side brush time left" + }, + "filter_time_left": { + "name": "Filter time left" + }, + "sensor_time_left": { + "name": "Sensor time left" + } + }, "select": { "mop_mode": { "name": "Mop mode", diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index d767505feeb5..f95436474e80 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -53,7 +53,12 @@ async def setup_entry( with patch( "homeassistant.components.roborock.RoborockApiClient.get_home_data", return_value=HOME_DATA, - ), patch("homeassistant.components.roborock.RoborockMqttClient.get_networking"): + ), patch( + "homeassistant.components.roborock.RoborockMqttClient.get_networking" + ), patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClient.get_prop", + return_value=PROP, + ): assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() return mock_roborock_entry diff --git a/tests/components/roborock/test_sensor.py b/tests/components/roborock/test_sensor.py new file mode 100644 index 000000000000..844435062083 --- /dev/null +++ b/tests/components/roborock/test_sensor.py @@ -0,0 +1,29 @@ +"""Test Roborock Sensors.""" + +from roborock.const import ( + FILTER_REPLACE_TIME, + MAIN_BRUSH_REPLACE_TIME, + SENSOR_DIRTY_REPLACE_TIME, + SIDE_BRUSH_REPLACE_TIME, +) + +from homeassistant.core import HomeAssistant + +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")) == 4 + assert hass.states.get("sensor.roborock_s7_maxv_main_brush_time_left").state == str( + MAIN_BRUSH_REPLACE_TIME - 74382 + ) + assert hass.states.get("sensor.roborock_s7_maxv_side_brush_time_left").state == str( + SIDE_BRUSH_REPLACE_TIME - 74382 + ) + assert hass.states.get("sensor.roborock_s7_maxv_filter_time_left").state == str( + FILTER_REPLACE_TIME - 74382 + ) + assert hass.states.get("sensor.roborock_s7_maxv_sensor_time_left").state == str( + SENSOR_DIRTY_REPLACE_TIME - 74382 + ) From 70c1fe8c6ca3c711a0ed4e30a51c08b1338a3eaf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 24 May 2023 13:54:11 -0500 Subject: [PATCH 528/822] Update integration tests to avoid patching utcnow (#93480) --- tests/components/integration/test_sensor.py | 28 ++++++++++----------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/tests/components/integration/test_sensor.py b/tests/components/integration/test_sensor.py index b2ad0b36b68e..7bbeeeb3e43c 100644 --- a/tests/components/integration/test_sensor.py +++ b/tests/components/integration/test_sensor.py @@ -1,7 +1,7 @@ """The tests for the integration sensor platform.""" from datetime import timedelta -from unittest.mock import patch +from freezegun import freeze_time import pytest from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass @@ -36,7 +36,7 @@ async def test_state(hass: HomeAssistant, method) -> None: } now = dt_util.utcnow() - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): assert await async_setup_component(hass, "sensor", config) entity_id = config["sensor"]["source"] @@ -51,7 +51,7 @@ async def test_state(hass: HomeAssistant, method) -> None: assert "device_class" not in state.attributes now += timedelta(seconds=3600) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 1, @@ -75,7 +75,7 @@ async def test_state(hass: HomeAssistant, method) -> None: # 1 hour after last update, power sensor is unavailable now += timedelta(seconds=3600) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, STATE_UNAVAILABLE, @@ -92,7 +92,7 @@ async def test_state(hass: HomeAssistant, method) -> None: # 1 hour after last update, power sensor is back to normal at 2 KiloWatts and stays for 1 hour += 2kWh now += timedelta(seconds=3600) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 2, @@ -111,7 +111,7 @@ async def test_state(hass: HomeAssistant, method) -> None: ) now += timedelta(seconds=3600) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 2, @@ -220,7 +220,7 @@ async def test_trapezoidal(hass: HomeAssistant) -> None: # Testing a power sensor with non-monotonic intervals and values for time, value in [(20, 10), (30, 30), (40, 5), (50, 0)]: now = dt_util.utcnow() + timedelta(minutes=time) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, value, @@ -260,7 +260,7 @@ async def test_left(hass: HomeAssistant) -> None: # Testing a power sensor with non-monotonic intervals and values for time, value in [(20, 10), (30, 30), (40, 5), (50, 0)]: now = dt_util.utcnow() + timedelta(minutes=time) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, value, @@ -300,7 +300,7 @@ async def test_right(hass: HomeAssistant) -> None: # Testing a power sensor with non-monotonic intervals and values for time, value in [(20, 10), (30, 30), (40, 5), (50, 0)]: now = dt_util.utcnow() + timedelta(minutes=time) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, value, @@ -336,7 +336,7 @@ async def test_prefix(hass: HomeAssistant) -> None: await hass.async_block_till_done() now = dt_util.utcnow() + timedelta(seconds=3600) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 1000, @@ -375,7 +375,7 @@ async def test_suffix(hass: HomeAssistant) -> None: await hass.async_block_till_done() now = dt_util.utcnow() + timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 1000, @@ -411,7 +411,7 @@ async def test_suffix_2(hass: HomeAssistant) -> None: await hass.async_block_till_done() now = dt_util.utcnow() + timedelta(hours=1) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 1000, @@ -556,7 +556,7 @@ async def test_calc_errors(hass: HomeAssistant, method) -> None: # Moving from an unknown state to a value is a calc error and should # not change the value of the Reimann sensor, unless the method used is "right". now += timedelta(seconds=3600) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set(entity_id, 0, {"device_class": None}) await hass.async_block_till_done() await hass.async_block_till_done() @@ -568,7 +568,7 @@ async def test_calc_errors(hass: HomeAssistant, method) -> None: # With the source sensor updated successfully, the Reimann sensor # should have a zero (known) value. now += timedelta(seconds=3600) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set(entity_id, 1, {"device_class": None}) await hass.async_block_till_done() await hass.async_block_till_done() From cff72f96534a833c0e06e7020a212518611a32b4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 24 May 2023 13:54:50 -0500 Subject: [PATCH 529/822] Update derivative tests to avoid patching utcnow (#93477) --- tests/components/derivative/test_sensor.py | 81 +++++++++++----------- 1 file changed, 41 insertions(+), 40 deletions(-) diff --git a/tests/components/derivative/test_sensor.py b/tests/components/derivative/test_sensor.py index c1541812d1b5..8260e5a0ada1 100644 --- a/tests/components/derivative/test_sensor.py +++ b/tests/components/derivative/test_sensor.py @@ -2,7 +2,8 @@ from datetime import timedelta from math import sin import random -from unittest.mock import patch + +from freezegun import freeze_time from homeassistant.const import UnitOfPower, UnitOfTime from homeassistant.core import HomeAssistant @@ -26,12 +27,11 @@ async def test_state(hass: HomeAssistant) -> None: entity_id = config["sensor"]["source"] base = dt_util.utcnow() - with patch("homeassistant.util.dt.utcnow") as now: - now.return_value = base + with freeze_time(base) as freezer: hass.states.async_set(entity_id, 1, {}) await hass.async_block_till_done() - now.return_value += timedelta(seconds=3600) + freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) hass.states.async_set(entity_id, 1, {}, force_update=True) await hass.async_block_till_done() @@ -68,9 +68,9 @@ async def setup_tests(hass, config, times, values, expected_state): # Testing a energy sensor with non-monotonic intervals and values base = dt_util.utcnow() - with patch("homeassistant.util.dt.utcnow") as now: + with freeze_time(base) as freezer: for time, value in zip(times, values): - now.return_value = base + timedelta(seconds=time) + freezer.move_to(base + timedelta(seconds=time)) hass.states.async_set(entity_id, value, {}, force_update=True) await hass.async_block_till_done() @@ -169,18 +169,19 @@ async def test_data_moving_average_for_discrete_sensor(hass: HomeAssistant) -> N ) # two minute window base = dt_util.utcnow() - for time, value in zip(times, temperature_values): - now = base + timedelta(seconds=time) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(base) as freezer: + for time, value in zip(times, temperature_values): + now = base + timedelta(seconds=time) + freezer.move_to(now) hass.states.async_set(entity_id, value, {}, force_update=True) await hass.async_block_till_done() - if time_window < time < times[-1] - time_window: - state = hass.states.get("sensor.power") - derivative = round(float(state.state), config["sensor"]["round"]) - # Test that the error is never more than - # (time_window_in_minutes / true_derivative * 100) = 10% + ε - assert abs(1 - derivative) <= 0.1 + 1e-6 + if time_window < time < times[-1] - time_window: + state = hass.states.get("sensor.power") + derivative = round(float(state.state), config["sensor"]["round"]) + # Test that the error is never more than + # (time_window_in_minutes / true_derivative * 100) = 10% + ε + assert abs(1 - derivative) <= 0.1 + 1e-6 async def test_data_moving_average_for_irregular_times(hass: HomeAssistant) -> None: @@ -212,18 +213,19 @@ async def test_data_moving_average_for_irregular_times(hass: HomeAssistant) -> N ) base = dt_util.utcnow() - for time, value in zip(times, temperature_values): - now = base + timedelta(seconds=time) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(base) as freezer: + for time, value in zip(times, temperature_values): + now = base + timedelta(seconds=time) + freezer.move_to(now) hass.states.async_set(entity_id, value, {}, force_update=True) await hass.async_block_till_done() - if time_window < time and time > times[3]: - state = hass.states.get("sensor.power") - derivative = round(float(state.state), config["sensor"]["round"]) - # Test that the error is never more than - # (time_window_in_minutes / true_derivative * 100) = 10% + ε - assert abs(0.1 - derivative) <= 0.01 + 1e-6 + if time_window < time and time > times[3]: + state = hass.states.get("sensor.power") + derivative = round(float(state.state), config["sensor"]["round"]) + # Test that the error is never more than + # (time_window_in_minutes / true_derivative * 100) = 10% + ε + assert abs(0.1 - derivative) <= 0.01 + 1e-6 async def test_double_signal_after_delay(hass: HomeAssistant) -> None: @@ -253,18 +255,19 @@ async def test_double_signal_after_delay(hass: HomeAssistant) -> None: base = dt_util.utcnow() previous = 0 - for time, value in zip(times, temperature_values): - now = base + timedelta(seconds=time) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(base) as freezer: + for time, value in zip(times, temperature_values): + now = base + timedelta(seconds=time) + freezer.move_to(now) hass.states.async_set(entity_id, value, {}, force_update=True) await hass.async_block_till_done() - state = hass.states.get("sensor.power") - derivative = round(float(state.state), config["sensor"]["round"]) - if time == times[-1]: - # Test that the error is never more than - # (time_window_in_minutes / true_derivative * 100) = 10% + ε - assert abs(previous - derivative) <= 0.01 + 1e-6 - previous = derivative + state = hass.states.get("sensor.power") + derivative = round(float(state.state), config["sensor"]["round"]) + if time == times[-1]: + # Test that the error is never more than + # (time_window_in_minutes / true_derivative * 100) = 10% + ε + assert abs(previous - derivative) <= 0.01 + 1e-6 + previous = derivative async def test_prefix(hass: HomeAssistant) -> None: @@ -283,8 +286,7 @@ async def test_prefix(hass: HomeAssistant) -> None: entity_id = config["sensor"]["source"] base = dt_util.utcnow() - with patch("homeassistant.util.dt.utcnow") as now: - now.return_value = base + with freeze_time(base) as freezer: hass.states.async_set( entity_id, 1000, @@ -293,7 +295,7 @@ async def test_prefix(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - now.return_value += timedelta(seconds=3600) + freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) hass.states.async_set( entity_id, 1000, @@ -327,12 +329,11 @@ async def test_suffix(hass: HomeAssistant) -> None: entity_id = config["sensor"]["source"] base = dt_util.utcnow() - with patch("homeassistant.util.dt.utcnow") as now: - now.return_value = base + with freeze_time(base) as freezer: hass.states.async_set(entity_id, 1000, {}) await hass.async_block_till_done() - now.return_value += timedelta(seconds=10) + freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) hass.states.async_set(entity_id, 1000, {}, force_update=True) await hass.async_block_till_done() From dc3826f68e0614a1eb5f95524b38f673faf2a6d6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 24 May 2023 13:57:56 -0500 Subject: [PATCH 530/822] Update generic_hygrostat tests to avoid patching utcnow (#93476) --- .../generic_hygrostat/test_humidifier.py | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/tests/components/generic_hygrostat/test_humidifier.py b/tests/components/generic_hygrostat/test_humidifier.py index f6952f6e246d..a87f2472fd30 100644 --- a/tests/components/generic_hygrostat/test_humidifier.py +++ b/tests/components/generic_hygrostat/test_humidifier.py @@ -1,7 +1,7 @@ """The tests for the generic_hygrostat.""" import datetime -from unittest.mock import patch +from freezegun import freeze_time import pytest import voluptuous as vol @@ -855,9 +855,7 @@ async def test_humidity_change_dry_trigger_on_long_enough( fake_changed = datetime.datetime( 1970, 11, 11, 11, 11, 11, tzinfo=datetime.timezone.utc ) - with patch( - "homeassistant.helpers.condition.dt_util.utcnow", return_value=fake_changed - ): + with freeze_time(fake_changed): calls = await _setup_switch(hass, False) _setup_sensor(hass, 35) await hass.async_block_till_done() @@ -893,9 +891,7 @@ async def test_humidity_change_dry_trigger_off_long_enough( fake_changed = datetime.datetime( 1970, 11, 11, 11, 11, 11, tzinfo=datetime.timezone.utc ) - with patch( - "homeassistant.helpers.condition.dt_util.utcnow", return_value=fake_changed - ): + with freeze_time(fake_changed): calls = await _setup_switch(hass, True) _setup_sensor(hass, 45) await hass.async_block_till_done() @@ -1021,9 +1017,7 @@ async def test_humidity_change_humidifier_trigger_on_long_enough( fake_changed = datetime.datetime( 1970, 11, 11, 11, 11, 11, tzinfo=datetime.timezone.utc ) - with patch( - "homeassistant.helpers.condition.dt_util.utcnow", return_value=fake_changed - ): + with freeze_time(fake_changed): calls = await _setup_switch(hass, False) _setup_sensor(hass, 45) await hass.async_block_till_done() @@ -1045,9 +1039,7 @@ async def test_humidity_change_humidifier_trigger_off_long_enough( fake_changed = datetime.datetime( 1970, 11, 11, 11, 11, 11, tzinfo=datetime.timezone.utc ) - with patch( - "homeassistant.helpers.condition.dt_util.utcnow", return_value=fake_changed - ): + with freeze_time(fake_changed): calls = await _setup_switch(hass, True) _setup_sensor(hass, 35) await hass.async_block_till_done() From 6057aeee2f9933ee4d86ae9088a5c91422c85e37 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 24 May 2023 20:59:14 +0200 Subject: [PATCH 531/822] Prevent NumberEntity and RestoreEntity inheritance (#93467) --- homeassistant/components/shelly/number.py | 1 + homeassistant/components/zha/number.py | 35 +++++++++++++++++++++++ pylint/plugins/hass_inheritance.py | 12 +++++++- 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/shelly/number.py b/homeassistant/components/shelly/number.py index fb95a180e76c..a89c74f9e509 100644 --- a/homeassistant/components/shelly/number.py +++ b/homeassistant/components/shelly/number.py @@ -85,6 +85,7 @@ async def async_setup_entry( ) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class BlockSleepingNumber(ShellySleepingBlockAttributeEntity, NumberEntity): """Represent a block sleeping number.""" diff --git a/homeassistant/components/zha/number.py b/homeassistant/components/zha/number.py index 6bc6f30a34f4..d24d0c566681 100644 --- a/homeassistant/components/zha/number.py +++ b/homeassistant/components/zha/number.py @@ -276,6 +276,7 @@ async def async_setup_entry( @STRICT_MATCH(cluster_handler_names=CLUSTER_HANDLER_ANALOG_OUTPUT) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class ZhaNumber(ZhaEntity, NumberEntity): """Representation of a ZHA Number entity.""" @@ -376,6 +377,7 @@ class ZhaNumber(ZhaEntity, NumberEntity): _LOGGER.debug("read value=%s", value) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class ZHANumberConfigurationEntity(ZhaEntity, NumberEntity): """Representation of a ZHA number configuration entity.""" @@ -458,6 +460,7 @@ class ZHANumberConfigurationEntity(ZhaEntity, NumberEntity): cluster_handler_names="opple_cluster", models={"lumi.motion.ac02", "lumi.motion.agl04"}, ) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class AqaraMotionDetectionInterval( ZHANumberConfigurationEntity, id_suffix="detection_interval" ): @@ -470,6 +473,7 @@ class AqaraMotionDetectionInterval( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEVEL) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class OnOffTransitionTimeConfigurationEntity( ZHANumberConfigurationEntity, id_suffix="on_off_transition_time" ): @@ -482,6 +486,7 @@ class OnOffTransitionTimeConfigurationEntity( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEVEL) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class OnLevelConfigurationEntity(ZHANumberConfigurationEntity, id_suffix="on_level"): """Representation of a ZHA on level configuration entity.""" @@ -492,6 +497,7 @@ class OnLevelConfigurationEntity(ZHANumberConfigurationEntity, id_suffix="on_lev @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEVEL) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class OnTransitionTimeConfigurationEntity( ZHANumberConfigurationEntity, id_suffix="on_transition_time" ): @@ -504,6 +510,7 @@ class OnTransitionTimeConfigurationEntity( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEVEL) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class OffTransitionTimeConfigurationEntity( ZHANumberConfigurationEntity, id_suffix="off_transition_time" ): @@ -516,6 +523,7 @@ class OffTransitionTimeConfigurationEntity( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEVEL) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class DefaultMoveRateConfigurationEntity( ZHANumberConfigurationEntity, id_suffix="default_move_rate" ): @@ -528,6 +536,7 @@ class DefaultMoveRateConfigurationEntity( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEVEL) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class StartUpCurrentLevelConfigurationEntity( ZHANumberConfigurationEntity, id_suffix="start_up_current_level" ): @@ -540,6 +549,7 @@ class StartUpCurrentLevelConfigurationEntity( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_COLOR) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class StartUpColorTemperatureConfigurationEntity( ZHANumberConfigurationEntity, id_suffix="start_up_color_temperature" ): @@ -570,6 +580,7 @@ class StartUpColorTemperatureConfigurationEntity( "_TZE200_htnnfasr", }, ) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class TimerDurationMinutes(ZHANumberConfigurationEntity, id_suffix="timer_duration"): """Representation of a ZHA timer duration configuration entity.""" @@ -583,6 +594,7 @@ class TimerDurationMinutes(ZHANumberConfigurationEntity, id_suffix="timer_durati @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names="ikea_airpurifier") +# pylint: disable-next=hass-invalid-inheritance # needs fixing class FilterLifeTime(ZHANumberConfigurationEntity, id_suffix="filter_life_time"): """Representation of a ZHA filter lifetime configuration entity.""" @@ -600,6 +612,7 @@ class FilterLifeTime(ZHANumberConfigurationEntity, id_suffix="filter_life_time") manufacturers={"TexasInstruments"}, models={"ti.router"}, ) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class TiRouterTransmitPower(ZHANumberConfigurationEntity, id_suffix="transmit_power"): """Representation of a ZHA TI transmit power configuration entity.""" @@ -610,6 +623,7 @@ class TiRouterTransmitPower(ZHANumberConfigurationEntity, id_suffix="transmit_po @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class InovelliRemoteDimmingUpSpeed( ZHANumberConfigurationEntity, id_suffix="dimming_speed_up_remote" ): @@ -624,6 +638,7 @@ class InovelliRemoteDimmingUpSpeed( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class InovelliButtonDelay(ZHANumberConfigurationEntity, id_suffix="button_delay"): """Inovelli button delay configuration entity.""" @@ -636,6 +651,7 @@ class InovelliButtonDelay(ZHANumberConfigurationEntity, id_suffix="button_delay" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class InovelliLocalDimmingUpSpeed( ZHANumberConfigurationEntity, id_suffix="dimming_speed_up_local" ): @@ -650,6 +666,7 @@ class InovelliLocalDimmingUpSpeed( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class InovelliLocalRampRateOffToOn( ZHANumberConfigurationEntity, id_suffix="ramp_rate_off_to_on_local" ): @@ -664,6 +681,7 @@ class InovelliLocalRampRateOffToOn( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class InovelliRemoteDimmingSpeedOffToOn( ZHANumberConfigurationEntity, id_suffix="ramp_rate_off_to_on_remote" ): @@ -678,6 +696,7 @@ class InovelliRemoteDimmingSpeedOffToOn( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class InovelliRemoteDimmingDownSpeed( ZHANumberConfigurationEntity, id_suffix="dimming_speed_down_remote" ): @@ -692,6 +711,7 @@ class InovelliRemoteDimmingDownSpeed( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class InovelliLocalDimmingDownSpeed( ZHANumberConfigurationEntity, id_suffix="dimming_speed_down_local" ): @@ -706,6 +726,7 @@ class InovelliLocalDimmingDownSpeed( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class InovelliLocalRampRateOnToOff( ZHANumberConfigurationEntity, id_suffix="ramp_rate_on_to_off_local" ): @@ -720,6 +741,7 @@ class InovelliLocalRampRateOnToOff( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class InovelliRemoteDimmingSpeedOnToOff( ZHANumberConfigurationEntity, id_suffix="ramp_rate_on_to_off_remote" ): @@ -734,6 +756,7 @@ class InovelliRemoteDimmingSpeedOnToOff( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class InovelliMinimumLoadDimmingLevel( ZHANumberConfigurationEntity, id_suffix="minimum_level" ): @@ -748,6 +771,7 @@ class InovelliMinimumLoadDimmingLevel( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class InovelliMaximumLoadDimmingLevel( ZHANumberConfigurationEntity, id_suffix="maximum_level" ): @@ -762,6 +786,7 @@ class InovelliMaximumLoadDimmingLevel( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class InovelliAutoShutoffTimer( ZHANumberConfigurationEntity, id_suffix="auto_off_timer" ): @@ -776,6 +801,7 @@ class InovelliAutoShutoffTimer( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class InovelliLoadLevelIndicatorTimeout( ZHANumberConfigurationEntity, id_suffix="load_level_indicator_timeout" ): @@ -790,6 +816,7 @@ class InovelliLoadLevelIndicatorTimeout( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class InovelliDefaultAllLEDOnColor( ZHANumberConfigurationEntity, id_suffix="led_color_when_on" ): @@ -804,6 +831,7 @@ class InovelliDefaultAllLEDOnColor( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class InovelliDefaultAllLEDOffColor( ZHANumberConfigurationEntity, id_suffix="led_color_when_off" ): @@ -818,6 +846,7 @@ class InovelliDefaultAllLEDOffColor( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class InovelliDefaultAllLEDOnIntensity( ZHANumberConfigurationEntity, id_suffix="led_intensity_when_on" ): @@ -832,6 +861,7 @@ class InovelliDefaultAllLEDOnIntensity( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class InovelliDefaultAllLEDOffIntensity( ZHANumberConfigurationEntity, id_suffix="led_intensity_when_off" ): @@ -846,6 +876,7 @@ class InovelliDefaultAllLEDOffIntensity( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class InovelliDoubleTapUpLevel( ZHANumberConfigurationEntity, id_suffix="double_tap_up_level" ): @@ -860,6 +891,7 @@ class InovelliDoubleTapUpLevel( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class InovelliDoubleTapDownLevel( ZHANumberConfigurationEntity, id_suffix="double_tap_down_level" ): @@ -876,6 +908,7 @@ class InovelliDoubleTapDownLevel( @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"} ) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class AqaraPetFeederServingSize(ZHANumberConfigurationEntity, id_suffix="serving_size"): """Aqara pet feeder serving size configuration entity.""" @@ -891,6 +924,7 @@ class AqaraPetFeederServingSize(ZHANumberConfigurationEntity, id_suffix="serving @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"} ) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class AqaraPetFeederPortionWeight( ZHANumberConfigurationEntity, id_suffix="portion_weight" ): @@ -909,6 +943,7 @@ class AqaraPetFeederPortionWeight( @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="opple_cluster", models={"lumi.airrtc.agl001"} ) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class AqaraThermostatAwayTemp( ZHANumberConfigurationEntity, id_suffix="away_preset_temperature" ): diff --git a/pylint/plugins/hass_inheritance.py b/pylint/plugins/hass_inheritance.py index afbe3453c7ea..716479202c74 100644 --- a/pylint/plugins/hass_inheritance.py +++ b/pylint/plugins/hass_inheritance.py @@ -45,7 +45,7 @@ class HassInheritanceChecker(BaseChecker): # type: ignore[misc] def visit_classdef(self, node: nodes.ClassDef) -> None: """Apply relevant type hint checks on a ClassDef node.""" - if self._module_platform != "sensor": + if self._module_platform not in {"number", "sensor"}: return ancestors = [a.name for a in node.ancestors()] @@ -59,6 +59,16 @@ class HassInheritanceChecker(BaseChecker): # type: ignore[misc] node=node, args="SensorEntity and RestoreEntity should not be combined, please use RestoreSensor", ) + elif ( + "RestoreEntity" in ancestors + and "NumberEntity" in ancestors + and "RestoreNumber" not in ancestors + ): + self.add_message( + "hass-invalid-inheritance", + node=node, + args="NumberEntity and RestoreEntity should not be combined, please use RestoreNumber", + ) def register(linter: PyLinter) -> None: From 68379dd55a8e80691e96c267835ef036950b7d48 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 24 May 2023 21:00:35 +0200 Subject: [PATCH 532/822] Improve TTS cache dir mocking (#93468) --- tests/components/assist_pipeline/conftest.py | 14 ++-- tests/components/cloud/conftest.py | 11 ++- tests/components/conftest.py | 8 +++ tests/components/google_translate/test_tts.py | 23 +++--- tests/components/marytts/test_tts.py | 11 +-- tests/components/microsoft/test_tts.py | 11 +-- tests/components/tts/conftest.py | 43 ++++++++---- tests/components/tts/test_init.py | 70 +++++++++---------- tests/components/tts/test_legacy.py | 4 +- tests/components/voicerss/test_tts.py | 23 +++--- tests/components/wyoming/test_tts.py | 10 +-- tests/components/yandextts/test_tts.py | 25 +++---- 12 files changed, 128 insertions(+), 125 deletions(-) diff --git a/tests/components/assist_pipeline/conftest.py b/tests/components/assist_pipeline/conftest.py index 54dc3705d2af..7ebc1de992de 100644 --- a/tests/components/assist_pipeline/conftest.py +++ b/tests/components/assist_pipeline/conftest.py @@ -23,15 +23,16 @@ from tests.common import ( mock_integration, mock_platform, ) -from tests.components.tts.conftest import ( # noqa: F401, pylint: disable=unused-import - init_cache_dir_side_effect, - mock_get_cache_files, - mock_init_cache_dir, -) _TRANSCRIPT = "test transcript" +@pytest.fixture(autouse=True) +def mock_tts_cache_dir_autouse(mock_tts_cache_dir): + """Mock the TTS cache dir with empty dir.""" + return mock_tts_cache_dir + + class BaseProvider: """Mock STT provider.""" @@ -190,9 +191,6 @@ async def init_supporting_components( mock_stt_provider_entity: MockSttProviderEntity, mock_tts_provider: MockTTSProvider, config_flow_fixture, - init_cache_dir_side_effect, # noqa: F811 - mock_get_cache_files, # noqa: F811 - mock_init_cache_dir, # noqa: F811 ): """Initialize relevant components with empty configs.""" diff --git a/tests/components/cloud/conftest.py b/tests/components/cloud/conftest.py index 93d3dc35bc34..221267c59fb0 100644 --- a/tests/components/cloud/conftest.py +++ b/tests/components/cloud/conftest.py @@ -8,12 +8,11 @@ from homeassistant.components.cloud import const, prefs from . import mock_cloud, mock_cloud_prefs -# Prevent TTS cache from being created -from tests.components.tts.conftest import ( # noqa: F401, pylint: disable=unused-import - init_cache_dir_side_effect, - mock_get_cache_files, - mock_init_cache_dir, -) + +@pytest.fixture(autouse=True) +def mock_tts_cache_dir_autouse(mock_tts_cache_dir): + """Mock the TTS cache dir with empty dir.""" + return mock_tts_cache_dir @pytest.fixture(autouse=True) diff --git a/tests/components/conftest.py b/tests/components/conftest.py index d57ef9768a00..576b3b3b0bc1 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -4,6 +4,14 @@ from unittest.mock import patch import pytest +from tests.components.tts.conftest import ( # noqa: F401, pylint: disable=unused-import + init_tts_cache_dir_side_effect_fixture, + mock_tts_cache_dir_fixture, + mock_tts_get_cache_files_fixture, + mock_tts_init_cache_dir_fixture, + tts_mutagen_mock_fixture, +) + @pytest.fixture(scope="session", autouse=True) def patch_zeroconf_multiple_catcher() -> Generator[None, None, None]: diff --git a/tests/components/google_translate/test_tts.py b/tests/components/google_translate/test_tts.py index 21386e5a8b31..6597507d334e 100644 --- a/tests/components/google_translate/test_tts.py +++ b/tests/components/google_translate/test_tts.py @@ -1,6 +1,4 @@ """The tests for the Google speech platform.""" -import os -import shutil from unittest.mock import patch from gtts import gTTSError @@ -18,7 +16,17 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component from tests.common import async_mock_service -from tests.components.tts.conftest import mutagen_mock # noqa: F401 + + +@pytest.fixture(autouse=True) +def tts_mutagen_mock_fixture_autouse(tts_mutagen_mock): + """Mock writing tags.""" + + +@pytest.fixture(autouse=True) +def mock_tts_cache_dir_autouse(mock_tts_cache_dir): + """Mock the TTS cache dir with empty dir.""" + return mock_tts_cache_dir async def get_media_source_url(hass, media_content_id): @@ -30,15 +38,6 @@ async def get_media_source_url(hass, media_content_id): return resolved.url -@pytest.fixture(autouse=True) -def cleanup_cache(hass): - """Clean up TTS cache.""" - yield - default_tts = hass.config.path(tts.DEFAULT_CACHE_DIR) - if os.path.isdir(default_tts): - shutil.rmtree(default_tts) - - @pytest.fixture async def calls(hass): """Mock media player calls.""" diff --git a/tests/components/marytts/test_tts.py b/tests/components/marytts/test_tts.py index 72cc65fbd0a7..4282b86ec2e2 100644 --- a/tests/components/marytts/test_tts.py +++ b/tests/components/marytts/test_tts.py @@ -1,6 +1,4 @@ """The tests for the MaryTTS speech platform.""" -import os -import shutil from unittest.mock import patch import pytest @@ -27,12 +25,9 @@ async def get_media_source_url(hass, media_content_id): @pytest.fixture(autouse=True) -def cleanup_cache(hass): - """Prevent TTS writing.""" - yield - default_tts = hass.config.path(tts.DEFAULT_CACHE_DIR) - if os.path.isdir(default_tts): - shutil.rmtree(default_tts) +def mock_tts_cache_dir_autouse(mock_tts_cache_dir): + """Mock the TTS cache dir with empty dir.""" + return mock_tts_cache_dir async def test_setup_component(hass: HomeAssistant) -> None: diff --git a/tests/components/microsoft/test_tts.py b/tests/components/microsoft/test_tts.py index d3f8e36e314d..2bdc32832a83 100644 --- a/tests/components/microsoft/test_tts.py +++ b/tests/components/microsoft/test_tts.py @@ -1,6 +1,4 @@ """Tests for Microsoft Text-to-Speech.""" -import os -import shutil from unittest.mock import patch from pycsspeechtts import pycsspeechtts @@ -32,12 +30,9 @@ async def get_media_source_url(hass: HomeAssistant, media_content_id): @pytest.fixture(autouse=True) -def cleanup_cache(hass: HomeAssistant): - """Clean up TTS cache.""" - yield - default_tts = hass.config.path(tts.DEFAULT_CACHE_DIR) - if os.path.isdir(default_tts): - shutil.rmtree(default_tts) +def mock_tts_cache_dir_autouse(mock_tts_cache_dir): + """Mock the TTS cache dir with empty dir.""" + return mock_tts_cache_dir @pytest.fixture diff --git a/tests/components/tts/conftest.py b/tests/components/tts/conftest.py index 43488808693f..d7ec722bbf20 100644 --- a/tests/components/tts/conftest.py +++ b/tests/components/tts/conftest.py @@ -38,8 +38,8 @@ def pytest_runtest_makereport(item, call): setattr(item, f"rep_{rep.when}", rep) -@pytest.fixture(autouse=True) -def mock_get_cache_files(): +@pytest.fixture(name="mock_tts_get_cache_files") +def mock_tts_get_cache_files_fixture(): """Mock the list TTS cache function.""" with patch( "homeassistant.components.tts._get_cache_files", return_value={} @@ -47,35 +47,37 @@ def mock_get_cache_files(): yield mock_cache_files -@pytest.fixture(autouse=True) -def mock_init_cache_dir( - init_cache_dir_side_effect: Any, +@pytest.fixture(name="mock_tts_init_cache_dir") +def mock_tts_init_cache_dir_fixture( + init_tts_cache_dir_side_effect: Any, ) -> Generator[MagicMock, None, None]: """Mock the TTS cache dir in memory.""" with patch( "homeassistant.components.tts._init_tts_cache_dir", - side_effect=init_cache_dir_side_effect, + side_effect=init_tts_cache_dir_side_effect, ) as mock_cache_dir: yield mock_cache_dir -@pytest.fixture -def init_cache_dir_side_effect() -> Any: +@pytest.fixture(name="init_tts_cache_dir_side_effect") +def init_tts_cache_dir_side_effect_fixture() -> Any: """Return the cache dir.""" return None -@pytest.fixture(autouse=True) -def empty_cache_dir(tmp_path, mock_init_cache_dir, mock_get_cache_files, request): +@pytest.fixture(name="mock_tts_cache_dir") +def mock_tts_cache_dir_fixture( + tmp_path, mock_tts_init_cache_dir, mock_tts_get_cache_files, request +): """Mock the TTS cache dir with empty dir.""" - mock_init_cache_dir.return_value = str(tmp_path) + mock_tts_init_cache_dir.return_value = str(tmp_path) # Restore original get cache files behavior, we're working with a real dir. - mock_get_cache_files.side_effect = _get_cache_files + mock_tts_get_cache_files.side_effect = _get_cache_files yield tmp_path - if request.node.rep_call.passed: + if not hasattr(request.node, "rep_call") or request.node.rep_call.passed: return # Print contents of dir if failed @@ -87,8 +89,14 @@ def empty_cache_dir(tmp_path, mock_init_cache_dir, mock_get_cache_files, request pytest.fail("Test failed, see log for details") -@pytest.fixture(autouse=True) -def mutagen_mock(): +@pytest.fixture(autouse=True, name="mock_tts_cache_dir") +def mock_tts_cache_dir_fixture_autouse(mock_tts_cache_dir): + """Mock the TTS cache dir with empty dir.""" + return mock_tts_cache_dir + + +@pytest.fixture(name="tts_mutagen_mock") +def tts_mutagen_mock_fixture(): """Mock writing tags.""" with patch( "homeassistant.components.tts.SpeechManager.write_tags", @@ -97,6 +105,11 @@ def mutagen_mock(): yield mock_write_tags +@pytest.fixture(autouse=True) +def tts_mutagen_mock_fixture_autouse(tts_mutagen_mock): + """Mock writing tags.""" + + @pytest.fixture(autouse=True) async def internal_url_mock(hass: HomeAssistant) -> None: """Mock internal URL of the instance.""" diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index b43687ba931a..31093b4cb836 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -148,12 +148,12 @@ async def test_setup_component(hass: HomeAssistant, setup: str) -> None: assert f"{tts.DOMAIN}.test" in hass.config.components -@pytest.mark.parametrize("init_cache_dir_side_effect", [OSError(2, "No access")]) +@pytest.mark.parametrize("init_tts_cache_dir_side_effect", [OSError(2, "No access")]) @pytest.mark.parametrize( "setup", ["mock_setup", "mock_config_entry_setup"], indirect=True ) async def test_setup_component_no_access_cache_folder( - hass: HomeAssistant, mock_init_cache_dir: MagicMock, setup: str + hass: HomeAssistant, mock_tts_init_cache_dir: MagicMock, setup: str ) -> None: """Set up a TTS platform with defaults.""" assert not hass.services.has_service(tts.DOMAIN, "test_say") @@ -187,7 +187,7 @@ async def test_setup_component_no_access_cache_folder( ) async def test_service( hass: HomeAssistant, - empty_cache_dir, + mock_tts_cache_dir, setup: str, tts_service: str, service_data: dict[str, Any], @@ -212,7 +212,7 @@ async def test_service( ) await hass.async_block_till_done() assert ( - empty_cache_dir + mock_tts_cache_dir / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_{expected_url_suffix}.mp3" ).is_file() @@ -248,7 +248,7 @@ async def test_service( ) async def test_service_default_language( hass: HomeAssistant, - empty_cache_dir, + mock_tts_cache_dir, setup: str, tts_service: str, service_data: dict[str, Any], @@ -271,7 +271,7 @@ async def test_service_default_language( ) await hass.async_block_till_done() assert ( - empty_cache_dir + mock_tts_cache_dir / ( f"42f18378fd4393d18c8dd11d03fa9563c1e54491_de-de_-_{expected_url_suffix}.mp3" ) @@ -309,7 +309,7 @@ async def test_service_default_language( ) async def test_service_default_special_language( hass: HomeAssistant, - empty_cache_dir, + mock_tts_cache_dir, setup: str, tts_service: str, service_data: dict[str, Any], @@ -332,7 +332,7 @@ async def test_service_default_special_language( ) await hass.async_block_till_done() assert ( - empty_cache_dir + mock_tts_cache_dir / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_{expected_url_suffix}.mp3" ).is_file() @@ -366,7 +366,7 @@ async def test_service_default_special_language( ) async def test_service_language( hass: HomeAssistant, - empty_cache_dir, + mock_tts_cache_dir, setup: str, tts_service: str, service_data: dict[str, Any], @@ -389,7 +389,7 @@ async def test_service_language( ) await hass.async_block_till_done() assert ( - empty_cache_dir + mock_tts_cache_dir / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_de-de_-_{expected_url_suffix}.mp3" ).is_file() @@ -423,7 +423,7 @@ async def test_service_language( ) async def test_service_wrong_language( hass: HomeAssistant, - empty_cache_dir, + mock_tts_cache_dir, setup: str, tts_service: str, service_data: dict[str, Any], @@ -441,7 +441,7 @@ async def test_service_wrong_language( ) assert len(calls) == 0 assert not ( - empty_cache_dir + mock_tts_cache_dir / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_lang_-_{expected_url_suffix}.mp3" ).is_file() @@ -477,7 +477,7 @@ async def test_service_wrong_language( ) async def test_service_options( hass: HomeAssistant, - empty_cache_dir, + mock_tts_cache_dir, setup: str, tts_service: str, service_data: dict[str, Any], @@ -502,7 +502,7 @@ async def test_service_options( ) await hass.async_block_till_done() assert ( - empty_cache_dir + mock_tts_cache_dir / ( "42f18378fd4393d18c8dd11d03fa9563c1e54491" f"_de-de_{opt_hash}_{expected_url_suffix}.mp3" @@ -561,7 +561,7 @@ class MockEntityWithDefaults(MockTTSEntity): ) async def test_service_default_options( hass: HomeAssistant, - empty_cache_dir, + mock_tts_cache_dir, setup: str, tts_service: str, service_data: dict[str, Any], @@ -586,7 +586,7 @@ async def test_service_default_options( ) await hass.async_block_till_done() assert ( - empty_cache_dir + mock_tts_cache_dir / ( "42f18378fd4393d18c8dd11d03fa9563c1e54491" f"_de-de_{opt_hash}_{expected_url_suffix}.mp3" @@ -629,7 +629,7 @@ async def test_service_default_options( ) async def test_merge_default_service_options( hass: HomeAssistant, - empty_cache_dir, + mock_tts_cache_dir, setup: str, tts_service: str, service_data: dict[str, Any], @@ -657,7 +657,7 @@ async def test_merge_default_service_options( ) await hass.async_block_till_done() assert ( - empty_cache_dir + mock_tts_cache_dir / ( "42f18378fd4393d18c8dd11d03fa9563c1e54491" f"_de-de_{opt_hash}_{expected_url_suffix}.mp3" @@ -696,7 +696,7 @@ async def test_merge_default_service_options( ) async def test_service_wrong_options( hass: HomeAssistant, - empty_cache_dir, + mock_tts_cache_dir, setup: str, tts_service: str, service_data: dict[str, Any], @@ -717,7 +717,7 @@ async def test_service_wrong_options( assert len(calls) == 0 await hass.async_block_till_done() assert not ( - empty_cache_dir + mock_tts_cache_dir / ( "42f18378fd4393d18c8dd11d03fa9563c1e54491" f"_de-de_{opt_hash}_{expected_url_suffix}.mp3" @@ -752,7 +752,7 @@ async def test_service_wrong_options( ) async def test_service_clear_cache( hass: HomeAssistant, - empty_cache_dir, + mock_tts_cache_dir, setup: str, tts_service: str, service_data: dict[str, Any], @@ -772,7 +772,7 @@ async def test_service_clear_cache( await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) await hass.async_block_till_done() assert ( - empty_cache_dir + mock_tts_cache_dir / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_{expected_url_suffix}.mp3" ).is_file() @@ -781,7 +781,7 @@ async def test_service_clear_cache( ) assert not ( - empty_cache_dir + mock_tts_cache_dir / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_{expected_url_suffix}.mp3" ).is_file() @@ -814,7 +814,7 @@ async def test_service_clear_cache( async def test_service_receive_voice( hass: HomeAssistant, hass_client: ClientSessionGenerator, - empty_cache_dir, + mock_tts_cache_dir, setup: str, tts_service: str, service_data: dict[str, Any], @@ -886,7 +886,7 @@ async def test_service_receive_voice( async def test_service_receive_voice_german( hass: HomeAssistant, hass_client: ClientSessionGenerator, - empty_cache_dir, + mock_tts_cache_dir, setup: str, tts_service: str, service_data: dict[str, Any], @@ -994,7 +994,7 @@ async def test_web_view_wrong_filename( ) async def test_service_without_cache( hass: HomeAssistant, - empty_cache_dir, + mock_tts_cache_dir, setup: str, tts_service: str, service_data: dict[str, Any], @@ -1012,7 +1012,7 @@ async def test_service_without_cache( await hass.async_block_till_done() assert len(calls) == 1 assert not ( - empty_cache_dir + mock_tts_cache_dir / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_{expected_url_suffix}.mp3" ).is_file() @@ -1042,7 +1042,7 @@ class MockEntityBoom(MockTTSEntity): @pytest.mark.parametrize("mock_provider", [MockProviderBoom(DEFAULT_LANG)]) async def test_setup_legacy_cache_dir( hass: HomeAssistant, - empty_cache_dir, + mock_tts_cache_dir, mock_provider: MockProvider, ) -> None: """Set up a TTS platform with cache and call service without cache.""" @@ -1050,7 +1050,7 @@ async def test_setup_legacy_cache_dir( tts_data = b"" cache_file = ( - empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_test.mp3" + mock_tts_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_test.mp3" ) with open(cache_file, "wb") as voice_file: @@ -1078,14 +1078,14 @@ async def test_setup_legacy_cache_dir( @pytest.mark.parametrize("mock_tts_entity", [MockEntityBoom(DEFAULT_LANG)]) async def test_setup_cache_dir( hass: HomeAssistant, - empty_cache_dir, + mock_tts_cache_dir, mock_tts_entity: MockTTSEntity, ) -> None: """Set up a TTS platform with cache and call service without cache.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) tts_data = b"" - cache_file = empty_cache_dir / ( + cache_file = mock_tts_cache_dir / ( "42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_tts.test.mp3" ) @@ -1182,13 +1182,13 @@ async def test_service_get_tts_error( async def test_load_cache_legacy_retrieve_without_mem_cache( hass: HomeAssistant, mock_provider: MockProvider, - empty_cache_dir, + mock_tts_cache_dir, hass_client: ClientSessionGenerator, ) -> None: """Set up component and load cache and get without mem cache.""" tts_data = b"" cache_file = ( - empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.mp3" + mock_tts_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.mp3" ) with open(cache_file, "wb") as voice_file: @@ -1208,12 +1208,12 @@ async def test_load_cache_legacy_retrieve_without_mem_cache( async def test_load_cache_retrieve_without_mem_cache( hass: HomeAssistant, mock_tts_entity: MockTTSEntity, - empty_cache_dir, + mock_tts_cache_dir, hass_client: ClientSessionGenerator, ) -> None: """Set up component and load cache and get without mem cache.""" tts_data = b"" - cache_file = empty_cache_dir / ( + cache_file = mock_tts_cache_dir / ( "42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_tts.test.mp3" ) diff --git a/tests/components/tts/test_legacy.py b/tests/components/tts/test_legacy.py index 0880fcf125a1..aa1efaf6f669 100644 --- a/tests/components/tts/test_legacy.py +++ b/tests/components/tts/test_legacy.py @@ -169,7 +169,7 @@ async def test_service_base_url_set(hass: HomeAssistant, mock_tts) -> None: async def test_service_without_cache_config( - hass: HomeAssistant, empty_cache_dir, mock_tts + hass: HomeAssistant, mock_tts_cache_dir, mock_tts ) -> None: """Set up a TTS platform without cache.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) @@ -191,5 +191,5 @@ async def test_service_without_cache_config( assert len(calls) == 1 await hass.async_block_till_done() assert not ( - empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_test.mp3" + mock_tts_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_test.mp3" ).is_file() diff --git a/tests/components/voicerss/test_tts.py b/tests/components/voicerss/test_tts.py index 7d89c36843c9..57a5b298162a 100644 --- a/tests/components/voicerss/test_tts.py +++ b/tests/components/voicerss/test_tts.py @@ -1,8 +1,6 @@ """The tests for the VoiceRSS speech platform.""" import asyncio from http import HTTPStatus -import os -import shutil import pytest @@ -17,7 +15,6 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component from tests.common import assert_setup_component, async_mock_service -from tests.components.tts.conftest import mutagen_mock # noqa: F401 from tests.test_util.aiohttp import AiohttpClientMocker URL = "https://api.voicerss.org/" @@ -30,6 +27,17 @@ FORM_DATA = { } +@pytest.fixture(autouse=True) +def tts_mutagen_mock_fixture_autouse(tts_mutagen_mock): + """Mock writing tags.""" + + +@pytest.fixture(autouse=True) +def mock_tts_cache_dir_autouse(mock_tts_cache_dir): + """Mock the TTS cache dir with empty dir.""" + return mock_tts_cache_dir + + async def get_media_source_url(hass, media_content_id): """Get the media source url.""" if media_source.DOMAIN not in hass.config.components: @@ -39,15 +47,6 @@ async def get_media_source_url(hass, media_content_id): return resolved.url -@pytest.fixture(autouse=True) -def cleanup_cache(hass): - """Prevent TTS writing.""" - yield - default_tts = hass.config.path(tts.DEFAULT_CACHE_DIR) - if os.path.isdir(default_tts): - shutil.rmtree(default_tts) - - async def test_setup_component(hass: HomeAssistant) -> None: """Test setup component.""" config = {tts.DOMAIN: {"platform": "voicerss", "api_key": "1234567xx"}} diff --git a/tests/components/wyoming/test_tts.py b/tests/components/wyoming/test_tts.py index f2a10710c26c..8767660ca087 100644 --- a/tests/components/wyoming/test_tts.py +++ b/tests/components/wyoming/test_tts.py @@ -15,11 +15,11 @@ from homeassistant.helpers.entity_component import DATA_INSTANCES from . import MockAsyncTcpClient -from tests.components.tts.conftest import ( # noqa: F401, pylint: disable=unused-import - init_cache_dir_side_effect, - mock_get_cache_files, - mock_init_cache_dir, -) + +@pytest.fixture(autouse=True) +def mock_tts_cache_dir_autouse(mock_tts_cache_dir): + """Mock the TTS cache dir with empty dir.""" + return mock_tts_cache_dir async def test_support(hass: HomeAssistant, init_wyoming_tts) -> None: diff --git a/tests/components/yandextts/test_tts.py b/tests/components/yandextts/test_tts.py index ed140041605f..d04aef6b16b8 100644 --- a/tests/components/yandextts/test_tts.py +++ b/tests/components/yandextts/test_tts.py @@ -1,8 +1,6 @@ """The tests for the Yandex SpeechKit speech platform.""" import asyncio from http import HTTPStatus -import os -import shutil import pytest @@ -16,14 +14,22 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from tests.common import assert_setup_component, async_mock_service -from tests.components.tts.conftest import ( # noqa: F401, pylint: disable=unused-import - mutagen_mock, -) from tests.test_util.aiohttp import AiohttpClientMocker URL = "https://tts.voicetech.yandex.net/generate?" +@pytest.fixture(autouse=True) +def tts_mutagen_mock_fixture_autouse(tts_mutagen_mock): + """Mock writing tags.""" + + +@pytest.fixture(autouse=True) +def mock_tts_cache_dir_autouse(mock_tts_cache_dir): + """Mock the TTS cache dir with empty dir.""" + return mock_tts_cache_dir + + async def get_media_source_url(hass, media_content_id): """Get the media source url.""" if media_source.DOMAIN not in hass.config.components: @@ -33,15 +39,6 @@ async def get_media_source_url(hass, media_content_id): return resolved.url -@pytest.fixture(autouse=True) -def cleanup_cache(hass): - """Prevent TTS writing.""" - yield - default_tts = hass.config.path(tts.DEFAULT_CACHE_DIR) - if os.path.isdir(default_tts): - shutil.rmtree(default_tts) - - async def test_setup_component(hass: HomeAssistant) -> None: """Test setup component.""" config = {tts.DOMAIN: {"platform": "yandextts", "api_key": "1234567xx"}} From 30d9d7d9057da24936a3a2af33b01d65421fec6e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 24 May 2023 21:02:55 +0200 Subject: [PATCH 533/822] Minor adjustment of tts typing (#93450) --- homeassistant/components/amazon_polly/tts.py | 5 +-- homeassistant/components/baidu/tts.py | 13 +++---- homeassistant/components/cloud/tts.py | 3 +- homeassistant/components/demo/tts.py | 2 +- homeassistant/components/google_cloud/tts.py | 2 +- .../components/google_translate/tts.py | 4 +- homeassistant/components/marytts/tts.py | 2 +- homeassistant/components/microsoft/tts.py | 2 +- homeassistant/components/picotts/tts.py | 2 +- homeassistant/components/tts/__init__.py | 39 ++++++++----------- homeassistant/components/tts/legacy.py | 4 +- homeassistant/components/voicerss/tts.py | 2 +- homeassistant/components/watson_tts/tts.py | 2 +- homeassistant/components/wyoming/tts.py | 4 +- homeassistant/components/yandextts/tts.py | 3 +- pylint/plugins/hass_enforce_type_hints.py | 2 +- tests/components/assist_pipeline/conftest.py | 2 +- tests/components/tts/common.py | 2 +- tests/components/tts/test_init.py | 12 +++--- tests/components/tts/test_media_source.py | 4 +- 20 files changed, 49 insertions(+), 62 deletions(-) diff --git a/homeassistant/components/amazon_polly/tts.py b/homeassistant/components/amazon_polly/tts.py index 97e0af7f18ed..5db46fc019e0 100644 --- a/homeassistant/components/amazon_polly/tts.py +++ b/homeassistant/components/amazon_polly/tts.py @@ -167,12 +167,9 @@ class AmazonPollyProvider(Provider): self, message: str, language: str, - options: dict[str, Any] | None = None, + options: dict[str, Any], ) -> TtsAudioType: """Request TTS file from Polly.""" - if options is None or language is None: - _LOGGER.debug("language and/or options were missing") - return None, None voice_id = options.get(CONF_VOICE, self.default_voice) voice_in_dict = self.all_voices[voice_id] if language != voice_in_dict.get("LanguageCode"): diff --git a/homeassistant/components/baidu/tts.py b/homeassistant/components/baidu/tts.py index 72694248fa1c..05e2956c10ec 100644 --- a/homeassistant/components/baidu/tts.py +++ b/homeassistant/components/baidu/tts.py @@ -104,7 +104,7 @@ class BaiduTTSProvider(Provider): """Return a list of supported options.""" return SUPPORTED_OPTIONS - def get_tts_audio(self, message, language, options=None): + def get_tts_audio(self, message, language, options): """Load TTS from BaiduTTS.""" aip_speech = AipSpeech( @@ -113,14 +113,11 @@ class BaiduTTSProvider(Provider): self._app_data["secretkey"], ) - if options is None: - result = aip_speech.synthesis(message, language, 1, self._speech_conf_data) - else: - speech_data = self._speech_conf_data.copy() - for key, value in options.items(): - speech_data[_OPTIONS[key]] = value + speech_data = self._speech_conf_data.copy() + for key, value in options.items(): + speech_data[_OPTIONS[key]] = value - result = aip_speech.synthesis(message, language, 1, speech_data) + result = aip_speech.synthesis(message, language, 1, speech_data) if isinstance(result, dict): _LOGGER.error( diff --git a/homeassistant/components/cloud/tts.py b/homeassistant/components/cloud/tts.py index 014b5c75d4c6..480ca06bf77c 100644 --- a/homeassistant/components/cloud/tts.py +++ b/homeassistant/components/cloud/tts.py @@ -134,12 +134,11 @@ class CloudProvider(Provider): } async def async_get_tts_audio( - self, message: str, language: str, options: dict[str, Any] | None = None + self, message: str, language: str, options: dict[str, Any] ) -> TtsAudioType: """Load TTS from NabuCasa Cloud.""" # Process TTS try: - assert options is not None data = await self.cloud.voice.process_tts( text=message, language=language, diff --git a/homeassistant/components/demo/tts.py b/homeassistant/components/demo/tts.py index 2c9cd654d842..43138f888e98 100644 --- a/homeassistant/components/demo/tts.py +++ b/homeassistant/components/demo/tts.py @@ -57,7 +57,7 @@ class DemoProvider(Provider): return ["voice", "age"] def get_tts_audio( - self, message: str, language: str, options: dict[str, Any] | None = None + self, message: str, language: str, options: dict[str, Any] ) -> TtsAudioType: """Load TTS from demo.""" filename = os.path.join(os.path.dirname(__file__), "tts.mp3") diff --git a/homeassistant/components/google_cloud/tts.py b/homeassistant/components/google_cloud/tts.py index 89da3433815b..c8f6869f6e4e 100644 --- a/homeassistant/components/google_cloud/tts.py +++ b/homeassistant/components/google_cloud/tts.py @@ -241,7 +241,7 @@ class GoogleCloudTTSProvider(Provider): CONF_TEXT_TYPE: self._text_type, } - async def async_get_tts_audio(self, message, language, options=None): + async def async_get_tts_audio(self, message, language, options): """Load TTS from google.""" options_schema = vol.Schema( { diff --git a/homeassistant/components/google_translate/tts.py b/homeassistant/components/google_translate/tts.py index b720498b4f1b..c02d262f6e5c 100644 --- a/homeassistant/components/google_translate/tts.py +++ b/homeassistant/components/google_translate/tts.py @@ -59,13 +59,13 @@ class GoogleProvider(Provider): """Return a list of supported options.""" return SUPPORT_OPTIONS - def get_tts_audio(self, message, language, options=None): + def get_tts_audio(self, message, language, options): """Load TTS from google.""" tld = self._tld if language in MAP_LANG_TLD: tld = MAP_LANG_TLD[language].tld language = MAP_LANG_TLD[language].lang - if options is not None and "tld" in options: + if "tld" in options: tld = options["tld"] tts = gTTS(text=message, lang=language, tld=tld) mp3_data = BytesIO() diff --git a/homeassistant/components/marytts/tts.py b/homeassistant/components/marytts/tts.py index 5904e271a6a9..56f31d81d971 100644 --- a/homeassistant/components/marytts/tts.py +++ b/homeassistant/components/marytts/tts.py @@ -80,7 +80,7 @@ class MaryTTSProvider(Provider): """Return a list of supported options.""" return SUPPORT_OPTIONS - def get_tts_audio(self, message, language, options=None): + def get_tts_audio(self, message, language, options): """Load TTS from MaryTTS.""" effects = options[CONF_EFFECT] diff --git a/homeassistant/components/microsoft/tts.py b/homeassistant/components/microsoft/tts.py index 7deb8f27c68d..ff35fcb561f6 100644 --- a/homeassistant/components/microsoft/tts.py +++ b/homeassistant/components/microsoft/tts.py @@ -176,7 +176,7 @@ class MicrosoftProvider(Provider): """Return a dict include default options.""" return {CONF_GENDER: self._gender, CONF_TYPE: self._type} - def get_tts_audio(self, message, language, options=None): + def get_tts_audio(self, message, language, options): """Load TTS from Microsoft.""" if language is None: language = self._lang diff --git a/homeassistant/components/picotts/tts.py b/homeassistant/components/picotts/tts.py index f9c93edb4fc0..23e94b5206d1 100644 --- a/homeassistant/components/picotts/tts.py +++ b/homeassistant/components/picotts/tts.py @@ -46,7 +46,7 @@ class PicoProvider(Provider): """Return list of supported languages.""" return SUPPORT_LANGUAGES - def get_tts_audio(self, message, language, options=None): + def get_tts_audio(self, message, language, options): """Load TTS using pico2wave.""" with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmpf: fname = tmpf.name diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 7a55fb9b77d0..254abbd0d637 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -364,7 +364,7 @@ class TextToSpeechEntity(RestoreEntity): @final async def internal_async_get_tts_audio( - self, message: str, language: str, options: dict[str, Any] | None = None + self, message: str, language: str, options: dict[str, Any] ) -> TtsAudioType: """Process an audio stream to TTS service. @@ -377,13 +377,13 @@ class TextToSpeechEntity(RestoreEntity): ) def get_tts_audio( - self, message: str, language: str, options: dict[str, Any] | None = None + self, message: str, language: str, options: dict[str, Any] ) -> TtsAudioType: """Load tts audio file from the engine.""" raise NotImplementedError() async def async_get_tts_audio( - self, message: str, language: str, options: dict[str, Any] | None = None + self, message: str, language: str, options: dict[str, Any] ) -> TtsAudioType: """Load tts audio file from the engine. @@ -478,9 +478,9 @@ class SpeechManager: def process_options( self, engine_instance: TextToSpeechEntity | Provider, - language: str | None = None, - options: dict | None = None, - ) -> tuple[str, dict | None]: + language: str | None, + options: dict | None, + ) -> tuple[str, dict[str, Any]]: """Validate and process options.""" # Languages language = language or engine_instance.default_language @@ -491,23 +491,18 @@ class SpeechManager: ): raise HomeAssistantError(f"Language '{language}' not supported") - # Options - if (default_options := engine_instance.default_options) and options: - merged_options = dict(default_options) - merged_options.update(options) - options = merged_options - if not options: - options = None if default_options is None else dict(default_options) + # Update default options with provided options + merged_options = dict(engine_instance.default_options or {}) + merged_options.update(options or {}) - if options is not None: - supported_options = engine_instance.supported_options or [] - invalid_opts = [ - opt_name for opt_name in options if opt_name not in supported_options - ] - if invalid_opts: - raise HomeAssistantError(f"Invalid options found: {invalid_opts}") + supported_options = engine_instance.supported_options or [] + invalid_opts = [ + opt_name for opt_name in merged_options if opt_name not in supported_options + ] + if invalid_opts: + raise HomeAssistantError(f"Invalid options found: {invalid_opts}") - return language, options + return language, merged_options async def async_get_url_path( self, @@ -602,7 +597,7 @@ class SpeechManager: message: str, cache: bool, language: str, - options: dict | None, + options: dict[str, Any], ) -> str: """Receive TTS, store for view in cache and return filename. diff --git a/homeassistant/components/tts/legacy.py b/homeassistant/components/tts/legacy.py index 138c0bf84c99..e4e146b72dec 100644 --- a/homeassistant/components/tts/legacy.py +++ b/homeassistant/components/tts/legacy.py @@ -240,13 +240,13 @@ class Provider: return None def get_tts_audio( - self, message: str, language: str, options: dict[str, Any] | None = None + self, message: str, language: str, options: dict[str, Any] ) -> TtsAudioType: """Load tts audio file from provider.""" raise NotImplementedError() async def async_get_tts_audio( - self, message: str, language: str, options: dict[str, Any] | None = None + self, message: str, language: str, options: dict[str, Any] ) -> TtsAudioType: """Load tts audio file from provider. diff --git a/homeassistant/components/voicerss/tts.py b/homeassistant/components/voicerss/tts.py index 2525393739b1..072e0ee431dd 100644 --- a/homeassistant/components/voicerss/tts.py +++ b/homeassistant/components/voicerss/tts.py @@ -187,7 +187,7 @@ class VoiceRSSProvider(Provider): """Return list of supported languages.""" return SUPPORT_LANGUAGES - async def async_get_tts_audio(self, message, language, options=None): + async def async_get_tts_audio(self, message, language, options): """Load TTS from VoiceRSS.""" websession = async_get_clientsession(self.hass) form_data = self._form_data.copy() diff --git a/homeassistant/components/watson_tts/tts.py b/homeassistant/components/watson_tts/tts.py index 08e910feae72..7af6c1ce97bf 100644 --- a/homeassistant/components/watson_tts/tts.py +++ b/homeassistant/components/watson_tts/tts.py @@ -180,7 +180,7 @@ class WatsonTTSProvider(Provider): """Return a list of supported options.""" return [CONF_VOICE] - def get_tts_audio(self, message, language=None, options=None): + def get_tts_audio(self, message, language, options): """Request TTS file from Watson TTS.""" response = self.service.synthesize( text=message, accept=self.output_format, voice=options[CONF_VOICE] diff --git a/homeassistant/components/wyoming/tts.py b/homeassistant/components/wyoming/tts.py index f2e314dc13ea..42439280a3b5 100644 --- a/homeassistant/components/wyoming/tts.py +++ b/homeassistant/components/wyoming/tts.py @@ -94,7 +94,7 @@ class WyomingTtsProvider(tts.TextToSpeechEntity): """Return a list of supported voices for a language.""" return self._voices.get(language) - async def async_get_tts_audio(self, message, language, options=None): + async def async_get_tts_audio(self, message, language, options): """Load TTS from UNIX socket.""" try: async with AsyncTcpClient(self.service.host, self.service.port) as client: @@ -129,7 +129,7 @@ class WyomingTtsProvider(tts.TextToSpeechEntity): except (OSError, WyomingError): return (None, None) - if (options is None) or (options[tts.ATTR_AUDIO_OUTPUT] == "wav"): + if options[tts.ATTR_AUDIO_OUTPUT] == "wav": return ("wav", data) # Raw output (convert to 16Khz, 16-bit mono) diff --git a/homeassistant/components/yandextts/tts.py b/homeassistant/components/yandextts/tts.py index 4cbbc679e423..755207c272d3 100644 --- a/homeassistant/components/yandextts/tts.py +++ b/homeassistant/components/yandextts/tts.py @@ -114,11 +114,10 @@ class YandexSpeechKitProvider(Provider): """Return list of supported options.""" return SUPPORTED_OPTIONS - async def async_get_tts_audio(self, message, language, options=None): + async def async_get_tts_audio(self, message, language, options): """Load TTS from yandex.""" websession = async_get_clientsession(self.hass) actual_language = language - options = options or {} try: async with async_timeout.timeout(10): diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index a3cdb1391315..dc9682173516 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -2449,7 +2449,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { ), TypeHintMatch( function_name="get_tts_audio", - arg_types={1: "str", 2: "str", 3: "dict[str, Any] | None"}, + arg_types={1: "str", 2: "str", 3: "dict[str, Any]"}, return_type="TtsAudioType", has_async_counterpart=True, ), diff --git a/tests/components/assist_pipeline/conftest.py b/tests/components/assist_pipeline/conftest.py index 7ebc1de992de..7b0b98d65a37 100644 --- a/tests/components/assist_pipeline/conftest.py +++ b/tests/components/assist_pipeline/conftest.py @@ -127,7 +127,7 @@ class MockTTSProvider(tts.Provider): return ["voice", "age", tts.ATTR_AUDIO_OUTPUT] def get_tts_audio( - self, message: str, language: str, options: dict[str, Any] | None = None + self, message: str, language: str, options: dict[str, Any] ) -> tts.TtsAudioType: """Load TTS data.""" return ("mp3", b"") diff --git a/tests/components/tts/common.py b/tests/components/tts/common.py index 1866273b627c..6f8de1364846 100644 --- a/tests/components/tts/common.py +++ b/tests/components/tts/common.py @@ -76,7 +76,7 @@ class BaseProvider: return ["voice", "age"] def get_tts_audio( - self, message: str, language: str, options: dict[str, Any] | None = None + self, message: str, language: str, options: dict[str, Any] ) -> TtsAudioType: """Load TTS dat.""" return ("mp3", b"") diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 31093b4cb836..0d110f25b50e 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -1021,7 +1021,7 @@ class MockProviderBoom(MockProvider): """Mock provider that blows up.""" def get_tts_audio( - self, message: str, language: str, options: dict[str, Any] | None = None + self, message: str, language: str, options: dict[str, Any] ) -> tts.TtsAudioType: """Load TTS dat.""" # This should not be called, data should be fetched from cache @@ -1032,7 +1032,7 @@ class MockEntityBoom(MockTTSEntity): """Mock entity that blows up.""" def get_tts_audio( - self, message: str, language: str, options: dict[str, Any] | None = None + self, message: str, language: str, options: dict[str, Any] ) -> tts.TtsAudioType: """Load TTS dat.""" # This should not be called, data should be fetched from cache @@ -1116,7 +1116,7 @@ class MockProviderEmpty(MockProvider): """Mock provider with empty get_tts_audio.""" def get_tts_audio( - self, message: str, language: str, options: dict[str, Any] | None = None + self, message: str, language: str, options: dict[str, Any] ) -> tts.TtsAudioType: """Load TTS dat.""" return (None, None) @@ -1126,7 +1126,7 @@ class MockEntityEmpty(MockTTSEntity): """Mock entity with empty get_tts_audio.""" def get_tts_audio( - self, message: str, language: str, options: dict[str, Any] | None = None + self, message: str, language: str, options: dict[str, Any] ) -> tts.TtsAudioType: """Load TTS dat.""" return (None, None) @@ -1486,7 +1486,7 @@ async def test_legacy_fetching_in_async( return {tts.ATTR_AUDIO_OUTPUT: "mp3"} async def async_get_tts_audio( - self, message: str, language: str, options: dict[str, Any] | None = None + self, message: str, language: str, options: dict[str, Any] ) -> tts.TtsAudioType: return ("mp3", await tts_audio) @@ -1559,7 +1559,7 @@ async def test_fetching_in_async( return {tts.ATTR_AUDIO_OUTPUT: "mp3"} async def async_get_tts_audio( - self, message: str, language: str, options: dict[str, Any] | None = None + self, message: str, language: str, options: dict[str, Any] ) -> tts.TtsAudioType: return ("mp3", await tts_audio) diff --git a/tests/components/tts/test_media_source.py b/tests/components/tts/test_media_source.py index ef2cbb651e8f..6065c97d6d65 100644 --- a/tests/components/tts/test_media_source.py +++ b/tests/components/tts/test_media_source.py @@ -103,7 +103,7 @@ async def test_legacy_resolving(hass: HomeAssistant, mock_provider: MSProvider) message, language = mock_get_tts_audio.mock_calls[0][1] assert message == "Hello World" assert language == "en_US" - assert mock_get_tts_audio.mock_calls[0][2]["options"] is None + assert mock_get_tts_audio.mock_calls[0][2]["options"] == {} # Pass language and options mock_get_tts_audio.reset_mock() @@ -138,7 +138,7 @@ async def test_resolving(hass: HomeAssistant, mock_tts_entity: MSEntity) -> None message, language = mock_get_tts_audio.mock_calls[0][1] assert message == "Hello World" assert language == "en_US" - assert mock_get_tts_audio.mock_calls[0][2]["options"] is None + assert mock_get_tts_audio.mock_calls[0][2]["options"] == {} # Pass language and options mock_get_tts_audio.reset_mock() From 5c6ed8f6d58efd0002100a4005ea156634ec26ae Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 24 May 2023 14:04:07 -0500 Subject: [PATCH 534/822] Use call_at for events instead call_later (#93431) Co-authored-by: Jan Bouwhuis --- homeassistant/helpers/event.py | 9 +++++---- tests/auth/test_init.py | 6 ++---- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 38d811c2afd7..4c06b0f23e05 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -1322,6 +1322,7 @@ def async_track_point_in_utc_time( # Since this is called once, we accept a HassJob so we can avoid # having to figure out how to call the action every time its called. cancel_callback: asyncio.TimerHandle | None = None + loop = hass.loop @callback def run_action(job: HassJob[[datetime], Coroutine[Any, Any, None] | None]) -> None: @@ -1335,7 +1336,7 @@ def async_track_point_in_utc_time( if (delta := (expected_fire_timestamp - time_tracker_timestamp())) > 0: _LOGGER.debug("Called %f seconds too early, rearming", delta) - cancel_callback = hass.loop.call_later(delta, run_action, job) + cancel_callback = loop.call_at(loop.time() + delta, run_action, job) return hass.async_run_hass_job(job, utc_point_in_time) @@ -1346,11 +1347,11 @@ def async_track_point_in_utc_time( else HassJob(action, f"track point in utc time {utc_point_in_time}") ) delta = expected_fire_timestamp - time.time() - cancel_callback = hass.loop.call_later(delta, run_action, job) + cancel_callback = loop.call_at(loop.time() + delta, run_action, job) @callback def unsub_point_in_time_listener() -> None: - """Cancel the call_later.""" + """Cancel the call_at.""" assert cancel_callback is not None cancel_callback.cancel() @@ -1382,7 +1383,7 @@ def async_call_later( if isinstance(action, HassJob) else HassJob(action, f"call_later {delay}") ) - cancel_callback = hass.loop.call_later(delay, run_action, job) + cancel_callback = hass.loop.call_at(hass.loop.time() + delay, run_action, job) @callback def unsub_call_later_listener() -> None: diff --git a/tests/auth/test_init.py b/tests/auth/test_init.py index 83c08dd73ee8..3cead230b1b8 100644 --- a/tests/auth/test_init.py +++ b/tests/auth/test_init.py @@ -1,7 +1,7 @@ """Tests for the Home Assistant auth module.""" from datetime import timedelta from typing import Any -from unittest.mock import Mock, patch +from unittest.mock import patch from freezegun import freeze_time import jwt @@ -31,10 +31,8 @@ from tests.common import ( @pytest.fixture -def mock_hass(event_loop): +def mock_hass(hass: HomeAssistant) -> HomeAssistant: """Home Assistant mock with minimum amount of data set to make it work with auth.""" - hass = Mock() - hass.config.skip_pip = True return hass From f5a235beee10d92f842f64ed44ad37c5271ee266 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Wed, 24 May 2023 21:18:59 +0200 Subject: [PATCH 535/822] Fix Airzone min/max climate temperatures (#93161) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * airzone: climate: fix max/min temps The library now provides AZD_ABS_TEMP_MAX/AZD_ABS_TEMP_MIN which are useful for devices with different max/min temperatures depending on the current working mode (HEAT vs COOL). These new values will have the highest/lowest max/min of both modes. Until now, the max/min of the current working mode (HEAT/COOL) would be set when starting Home Assistant, limiting the temperature range if the device mode was changed after that. Signed-off-by: Álvaro Fernández Rojas * airzone: climate: update max/min temps Some devices have different max/min climate temps depending on the active mode (HEAT vs COOL), so we should update these values. Signed-off-by: Álvaro Fernández Rojas * Revert "airzone: climate: update max/min temps" This reverts commit 988194d48614e2326a45b31ffcb8730b8172ac28. * Revert "Revert "airzone: climate: update max/min temps"" This reverts commit e4ead24f71cf2a16faec9253046419a59136daa1. * tests: airzone: add max/min climate changes test Signed-off-by: Álvaro Fernández Rojas * tests: airzone: fix dict copy Signed-off-by: Álvaro Fernández Rojas --------- Signed-off-by: Álvaro Fernández Rojas --- homeassistant/components/airzone/climate.py | 4 +-- tests/components/airzone/test_climate.py | 35 ++++++++++++++++++++- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airzone/climate.py b/homeassistant/components/airzone/climate.py index fda007cdc823..3c47c333b92a 100644 --- a/homeassistant/components/airzone/climate.py +++ b/homeassistant/components/airzone/climate.py @@ -131,8 +131,6 @@ class AirzoneClimate(AirzoneZoneEntity, ClimateEntity): self._attr_unique_id = f"{self._attr_unique_id}_{system_zone_id}" self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE self._attr_target_temperature_step = API_TEMPERATURE_STEP - self._attr_max_temp = self.get_airzone_value(AZD_TEMP_MAX) - self._attr_min_temp = self.get_airzone_value(AZD_TEMP_MIN) self._attr_temperature_unit = TEMP_UNIT_LIB_TO_HASS[ self.get_airzone_value(AZD_TEMP_UNIT) ] @@ -240,6 +238,8 @@ class AirzoneClimate(AirzoneZoneEntity, ClimateEntity): ] else: self._attr_hvac_mode = HVACMode.OFF + 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) if self.supported_features & ClimateEntityFeature.FAN_MODE: self._attr_fan_mode = self._speeds.get(self.get_airzone_value(AZD_SPEED)) diff --git a/tests/components/airzone/test_climate.py b/tests/components/airzone/test_climate.py index 0baac5c9d71a..f51dd3182400 100644 --- a/tests/components/airzone/test_climate.py +++ b/tests/components/airzone/test_climate.py @@ -6,17 +6,21 @@ from aioairzone.const import ( API_COOL_SET_POINT, API_DATA, API_HEAT_SET_POINT, + API_MAX_TEMP, + API_MIN_TEMP, API_MODE, API_ON, API_SET_POINT, API_SPEED, API_SYSTEM_ID, + API_SYSTEMS, API_ZONE_ID, ) from aioairzone.exceptions import AirzoneError import pytest from homeassistant.components.airzone.const import API_TEMPERATURE_STEP +from homeassistant.components.airzone.coordinator import SCAN_INTERVAL from homeassistant.components.climate import ( ATTR_CURRENT_HUMIDITY, ATTR_CURRENT_TEMPERATURE, @@ -49,8 +53,16 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.util.dt import utcnow -from .util import async_init_integration +from .util import ( + HVAC_MOCK, + HVAC_SYSTEMS_MOCK, + HVAC_WEBSERVER_MOCK, + async_init_integration, +) + +from tests.common import async_fire_time_changed async def test_airzone_create_climates(hass: HomeAssistant) -> None: @@ -211,6 +223,27 @@ async def test_airzone_create_climates(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_TARGET_TEMP_STEP) == API_TEMPERATURE_STEP assert state.attributes.get(ATTR_TEMPERATURE) == 22.8 + HVAC_MOCK_CHANGED = {**HVAC_MOCK} + HVAC_MOCK_CHANGED[API_SYSTEMS][0][API_DATA][0][API_MAX_TEMP] = 25 + HVAC_MOCK_CHANGED[API_SYSTEMS][0][API_DATA][0][API_MIN_TEMP] = 10 + + with patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_hvac", + return_value=HVAC_MOCK_CHANGED, + ), patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_hvac_systems", + return_value=HVAC_SYSTEMS_MOCK, + ), patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_webserver", + return_value=HVAC_WEBSERVER_MOCK, + ): + async_fire_time_changed(hass, utcnow() + SCAN_INTERVAL) + await hass.async_block_till_done() + + state = hass.states.get("climate.salon") + assert state.attributes.get(ATTR_MAX_TEMP) == 25 + assert state.attributes.get(ATTR_MIN_TEMP) == 10 + async def test_airzone_climate_turn_on_off(hass: HomeAssistant) -> None: """Test turning on.""" From 46c63dd70b3a5016cc4667ea8b3841db30eb44a6 Mon Sep 17 00:00:00 2001 From: Luke Date: Wed, 24 May 2023 15:35:05 -0400 Subject: [PATCH 536/822] Fix Roborock Sensor test failing (#93488) fix roborock sensors --- homeassistant/components/roborock/sensor.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index 5841641d6cba..8f14b227a9cf 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from roborock.typing import DeviceProp +from roborock.roborock_typing import DeviceProp from homeassistant.components.sensor import ( SensorDeviceClass, @@ -21,7 +21,6 @@ from homeassistant.util import slugify from .const import DOMAIN from .coordinator import RoborockDataUpdateCoordinator from .device import RoborockCoordinatedEntity -from .models import RoborockHassDeviceInfo @dataclass @@ -84,18 +83,16 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Roborock vacuum sensors.""" - coordinator: RoborockDataUpdateCoordinator = hass.data[DOMAIN][ + coordinators: dict[str, RoborockDataUpdateCoordinator] = hass.data[DOMAIN][ config_entry.entry_id ] - async_add_entities( RoborockSensorEntity( f"{description.key}_{slugify(device_id)}", - device_info, coordinator, description, ) - for device_id, device_info in coordinator.devices_info.items() + for device_id, coordinator in coordinators.items() for description in CONSUMABLE_SENSORS ) @@ -108,15 +105,14 @@ class RoborockSensorEntity(RoborockCoordinatedEntity, SensorEntity): def __init__( self, unique_id: str, - device_info: RoborockHassDeviceInfo, coordinator: RoborockDataUpdateCoordinator, description: RoborockSensorDescription, ) -> None: """Initialize the entity.""" - super().__init__(unique_id, device_info, coordinator) + super().__init__(unique_id, coordinator) self.entity_description = description @property def native_value(self) -> StateType: """Return the value reported by the sensor.""" - return self.entity_description.value_fn(self.coordinator.data[self._device_id]) + return self.entity_description.value_fn(self.coordinator.device_info.props) From f0874791d81e0c3e74e9b6ae95934a117d7a4717 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Wed, 24 May 2023 15:37:33 -0400 Subject: [PATCH 537/822] Refactor zwave_js.cover (#93433) * Refactor zwave_js.cover and improve test coverage * Remove extra fixtures * cleanup old stuff * Get coverage to 100 * Remove redundant stuff * Revert all changes to tests * Update conftest.py --- homeassistant/components/zwave_js/const.py | 2 + homeassistant/components/zwave_js/cover.py | 319 +++++++++++------- .../components/zwave_js/discovery.py | 12 +- .../zwave_js/discovery_data_template.py | 33 +- 4 files changed, 235 insertions(+), 131 deletions(-) diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index 3967709ccc8b..411d8b911b84 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -1,4 +1,6 @@ """Constants for the Z-Wave JS integration.""" +from __future__ import annotations + import logging from homeassistant.const import APPLICATION_NAME, __version__ as HA_VERSION diff --git a/homeassistant/components/zwave_js/cover.py b/homeassistant/components/zwave_js/cover.py index 686a186a7cbc..907d98f9c33a 100644 --- a/homeassistant/components/zwave_js/cover.py +++ b/homeassistant/components/zwave_js/cover.py @@ -54,7 +54,7 @@ async def async_setup_entry( elif info.platform_hint and info.platform_hint.endswith("tilt"): entities.append(ZWaveTiltCover(config_entry, driver, info)) else: - entities.append(ZWaveCover(config_entry, driver, info)) + entities.append(ZWaveMultilevelSwitchCover(config_entry, driver, info)) async_add_entities(entities) config_entry.async_on_unload( @@ -66,44 +66,199 @@ async def async_setup_entry( ) -def percent_to_zwave_position(value: int) -> int: - """Convert position in 0-100 scale to 0-99 scale. +class CoverPositionMixin(ZWaveBaseEntity, CoverEntity): + """Mix-in class for cover with position support.""" - `value` -- (int) Position byte value from 0-100. - """ - if value > 0: - return max(1, round((value / 100) * 99)) - return 0 + _current_position_value: ZwaveValue | None = None + _target_position_value: ZwaveValue | None = None + _stop_position_value: ZwaveValue | None = None + + def _set_position_values( + self, + current_value: ZwaveValue, + target_value: ZwaveValue | None = None, + stop_value: ZwaveValue | None = None, + ) -> None: + """Set values for position.""" + self._attr_supported_features = ( + (self._attr_supported_features or 0) + | CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.SET_POSITION + ) + self._current_position_value = current_value + self._target_position_value = target_value or self.get_zwave_value( + TARGET_VALUE_PROPERTY, value_property_key=current_value.property_key + ) + + if stop_value: + self._stop_position_value = stop_value + self._attr_supported_features |= CoverEntityFeature.STOP + + def percent_to_zwave_position(self, value: int) -> int: + """Convert position in 0-100 scale to closed_value-open_value scale.""" + return ( + round(max(min(1, (value / 100)), 0) * self._position_range) + + self._fully_closed_position + ) + + def zwave_to_percent_position(self, value: int) -> int: + """Convert closed_value-open_value scale to position in 0-100 scale.""" + return round( + ((value - self._fully_closed_position) / self._position_range) * 100 + ) + + @property + def _fully_open_position(self) -> int: + """Return value that represents fully opened position.""" + max_ = self.info.primary_value.metadata.max + return 99 if max_ is None else max_ + + @property + def _fully_closed_position(self) -> int: + """Return value that represents fully closed position.""" + min_ = self.info.primary_value.metadata.min + return 0 if min_ is None else min_ + + @property + def _position_range(self) -> int: + """Return range between fully opened and fully closed position.""" + return self._fully_open_position - self._fully_closed_position + + @property + def is_closed(self) -> bool | None: + """Return true if cover is closed.""" + if not (value := self._current_position_value) or value.value is None: + return None + return bool(value.value == 0) + + @property + def current_cover_position(self) -> int | None: + """Return the current position of cover where 0 means closed and 100 is fully open.""" + if ( + self._current_position_value is None + or self._current_position_value.value is None + ): + # guard missing value + return None + return self.zwave_to_percent_position(self._current_position_value.value) + + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Move the cover to a specific position.""" + assert self._target_position_value + await self.info.node.async_set_value( + self._target_position_value, + self.percent_to_zwave_position(kwargs[ATTR_POSITION]), + ) + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the cover.""" + assert self._target_position_value + await self.info.node.async_set_value( + self._target_position_value, self._fully_open_position + ) + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close cover.""" + assert self._target_position_value + await self.info.node.async_set_value( + self._target_position_value, self._fully_closed_position + ) + + async def async_stop_cover(self, **kwargs: Any) -> None: + """Stop cover.""" + assert self._stop_position_value + # Stop the cover, will stop regardless of the actual direction of travel. + await self.info.node.async_set_value(self._stop_position_value, False) -def percent_to_zwave_tilt(value: int) -> int: - """Convert position in 0-100 scale to 0-99 scale. +class CoverTiltMixin(ZWaveBaseEntity, CoverEntity): + """Mix-in class for cover with tilt support.""" - `value` -- (int) Position byte value from 0-100. - """ - if value > 0: - return round((value / 100) * 99) - return 0 + _current_tilt_value: ZwaveValue | None = None + _target_tilt_value: ZwaveValue | None = None + _stop_tilt_value: ZwaveValue | None = None + + def _set_tilt_values( + self, + current_value: ZwaveValue, + target_value: ZwaveValue | None = None, + ) -> None: + """Set values for tilt.""" + self._attr_supported_features = ( + (self._attr_supported_features or 0) + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.SET_TILT_POSITION + ) + self._current_tilt_value = current_value + self._target_tilt_value = target_value or self.get_zwave_value( + TARGET_VALUE_PROPERTY, value_property_key=current_value.property_key + ) + + def percent_to_zwave_tilt(self, value: int) -> int: + """Convert position in 0-100 scale to closed_value-open_value scale.""" + return ( + round(max(min(1, (value / 100)), 0) * self._tilt_range) + + self._fully_closed_tilt + ) + + def zwave_to_percent_tilt(self, value: int) -> int: + """Convert closed_value-open_value scale to position in 0-100 scale.""" + return round(((value - self._fully_closed_tilt) / self._tilt_range) * 100) + + @property + def _fully_open_tilt(self) -> int: + """Return value that represents fully opened tilt.""" + max_ = self.info.primary_value.metadata.max + return 99 if max_ is None else max_ + + @property + def _fully_closed_tilt(self) -> int: + """Return value that represents fully closed tilt.""" + min_ = self.info.primary_value.metadata.min + return 0 if min_ is None else min_ + + @property + def _tilt_range(self) -> int: + """Return range between fully opened and fully closed tilt.""" + return self._fully_open_tilt - self._fully_closed_tilt + + @property + def current_cover_tilt_position(self) -> int | None: + """Return current position of cover tilt. + + None is unknown, 0 is closed, 100 is fully open. + """ + if (value := self._current_tilt_value) is None or value.value is None: + return None + return self.zwave_to_percent_tilt(int(value.value)) + + async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: + """Move the cover tilt to a specific position.""" + assert self._target_tilt_value + await self.info.node.async_set_value( + self._target_tilt_value, + self.percent_to_zwave_tilt(kwargs[ATTR_TILT_POSITION]), + ) + + async def async_open_cover_tilt(self, **kwargs: Any) -> None: + """Open the cover tilt.""" + assert self._target_tilt_value + await self.info.node.async_set_value( + self._target_tilt_value, self._fully_open_tilt + ) + + async def async_close_cover_tilt(self, **kwargs: Any) -> None: + """Close the cover tilt.""" + assert self._target_tilt_value + await self.info.node.async_set_value( + self._target_tilt_value, self._fully_closed_tilt + ) -def zwave_tilt_to_percent(value: int) -> int: - """Convert 0-99 scale to position in 0-100 scale. - - `value` -- (int) Position byte value from 0-99. - """ - if value > 0: - return round((value / 99) * 100) - return 0 - - -class ZWaveCover(ZWaveBaseEntity, CoverEntity): - """Representation of a Z-Wave Cover device.""" - - _attr_supported_features = ( - CoverEntityFeature.OPEN - | CoverEntityFeature.CLOSE - | CoverEntityFeature.SET_POSITION - ) +class ZWaveMultilevelSwitchCover(CoverPositionMixin): + """Representation of a Z-Wave Cover that uses Multilevel Switch CC for position.""" def __init__( self, @@ -113,16 +268,15 @@ class ZWaveCover(ZWaveBaseEntity, CoverEntity): ) -> None: """Initialize a ZWaveCover entity.""" super().__init__(config_entry, driver, info) - - self._stop_cover_value = ( - self.get_zwave_value(COVER_OPEN_PROPERTY) - or self.get_zwave_value(COVER_UP_PROPERTY) - or self.get_zwave_value(COVER_ON_PROPERTY) + self._set_position_values( + self.info.primary_value, + stop_value=( + self.get_zwave_value(COVER_OPEN_PROPERTY) + or self.get_zwave_value(COVER_UP_PROPERTY) + or self.get_zwave_value(COVER_ON_PROPERTY) + ), ) - if self._stop_cover_value: - self._attr_supported_features |= CoverEntityFeature.STOP - # Entity class attributes self._attr_device_class = CoverDeviceClass.WINDOW if self.info.platform_hint and self.info.platform_hint.startswith("shutter"): @@ -130,50 +284,8 @@ class ZWaveCover(ZWaveBaseEntity, CoverEntity): if self.info.platform_hint and self.info.platform_hint.startswith("blind"): self._attr_device_class = CoverDeviceClass.BLIND - @property - def is_closed(self) -> bool | None: - """Return true if cover is closed.""" - if self.info.primary_value.value is None: - # guard missing value - return None - return bool(self.info.primary_value.value == 0) - @property - def current_cover_position(self) -> int | None: - """Return the current position of cover where 0 means closed and 100 is fully open.""" - if self.info.primary_value.value is None: - # guard missing value - return None - return round((cast(int, self.info.primary_value.value) / 99) * 100) - - async def async_set_cover_position(self, **kwargs: Any) -> None: - """Move the cover to a specific position.""" - target_value = self.get_zwave_value(TARGET_VALUE_PROPERTY) - assert target_value is not None - await self.info.node.async_set_value( - target_value, percent_to_zwave_position(kwargs[ATTR_POSITION]) - ) - - async def async_open_cover(self, **kwargs: Any) -> None: - """Open the cover.""" - target_value = self.get_zwave_value(TARGET_VALUE_PROPERTY) - assert target_value is not None - await self.info.node.async_set_value(target_value, 99) - - async def async_close_cover(self, **kwargs: Any) -> None: - """Close cover.""" - target_value = self.get_zwave_value(TARGET_VALUE_PROPERTY) - assert target_value is not None - await self.info.node.async_set_value(target_value, 0) - - async def async_stop_cover(self, **kwargs: Any) -> None: - """Stop cover.""" - assert self._stop_cover_value - # Stop the cover, will stop regardless of the actual direction of travel. - await self.info.node.async_set_value(self._stop_cover_value, False) - - -class ZWaveTiltCover(ZWaveCover): +class ZWaveTiltCover(ZWaveMultilevelSwitchCover, CoverTiltMixin): """Representation of a Z-Wave cover device with tilt.""" def __init__( @@ -185,43 +297,12 @@ class ZWaveTiltCover(ZWaveCover): """Initialize a ZWaveCover entity.""" super().__init__(config_entry, driver, info) - self._current_tilt_value = cast( - CoverTiltDataTemplate, self.info.platform_data_template - ).current_tilt_value(self.info.platform_data) - - self._attr_supported_features |= ( - CoverEntityFeature.OPEN_TILT - | CoverEntityFeature.CLOSE_TILT - | CoverEntityFeature.SET_TILT_POSITION + template = cast(CoverTiltDataTemplate, self.info.platform_data_template) + self._set_tilt_values( + template.current_tilt_value(self.info.platform_data), + template.target_tilt_value(self.info.platform_data), ) - @property - def current_cover_tilt_position(self) -> int | None: - """Return current position of cover tilt. - - None is unknown, 0 is closed, 100 is fully open. - """ - value = self._current_tilt_value - if value is None or value.value is None: - return None - return zwave_tilt_to_percent(int(value.value)) - - async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: - """Move the cover tilt to a specific position.""" - assert self._current_tilt_value - await self.info.node.async_set_value( - self._current_tilt_value, - percent_to_zwave_tilt(kwargs[ATTR_TILT_POSITION]), - ) - - async def async_open_cover_tilt(self, **kwargs: Any) -> None: - """Open the cover tilt.""" - await self.async_set_cover_tilt_position(tilt_position=100) - - async def async_close_cover_tilt(self, **kwargs: Any) -> None: - """Close the cover tilt.""" - await self.async_set_cover_tilt_position(tilt_position=0) - class ZwaveMotorizedBarrier(ZWaveBaseEntity, CoverEntity): """Representation of a Z-Wave motorized barrier device.""" diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 4e7d7f7748a4..7c49ee7f61b0 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -363,12 +363,18 @@ DISCOVERY_SCHEMAS = [ product_type={0x0301, 0x0302}, primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, data_template=CoverTiltDataTemplate( - tilt_value_id=ZwaveValueID( + current_tilt_value_id=ZwaveValueID( property_="fibaro", command_class=CommandClass.MANUFACTURER_PROPRIETARY, endpoint=0, property_key="venetianBlindsTilt", - ) + ), + target_tilt_value_id=ZwaveValueID( + property_="fibaro", + command_class=CommandClass.MANUFACTURER_PROPRIETARY, + endpoint=0, + property_key="venetianBlindsTilt", + ), ), required_values=[ ZWaveValueDiscoverySchema( @@ -854,7 +860,7 @@ DISCOVERY_SCHEMAS = [ # window coverings ZWaveDiscoverySchema( platform=Platform.COVER, - hint="cover", + hint="multilevel_switch", device_class_generic={"Multilevel Switch"}, device_class_specific={ "Motor Control Class A", diff --git a/homeassistant/components/zwave_js/discovery_data_template.py b/homeassistant/components/zwave_js/discovery_data_template.py index 249a175d4d29..17a6e7c7522e 100644 --- a/homeassistant/components/zwave_js/discovery_data_template.py +++ b/homeassistant/components/zwave_js/discovery_data_template.py @@ -392,31 +392,46 @@ class NumericSensorDataTemplate(BaseDiscoverySchemaDataTemplate): @dataclass class TiltValueMix: - """Mixin data class for the tilt_value.""" + """Mixin data class for the current_tilt_value and target_tilt_value.""" - tilt_value_id: ZwaveValueID + current_tilt_value_id: ZwaveValueID + target_tilt_value_id: ZwaveValueID @dataclass class CoverTiltDataTemplate(BaseDiscoverySchemaDataTemplate, TiltValueMix): """Tilt data template class for Z-Wave Cover entities.""" - def resolve_data(self, value: ZwaveValue) -> dict[str, ZwaveValue | None]: + def resolve_data(self, value: ZwaveValue) -> dict[str, ZwaveValue]: """Resolve helper class data for a discovered value.""" - return {"tilt_value": self._get_value_from_id(value.node, self.tilt_value_id)} + current_tilt_value = self._get_value_from_id( + value.node, self.current_tilt_value_id + ) + assert current_tilt_value + target_tilt_value = self._get_value_from_id( + value.node, self.target_tilt_value_id + ) + assert target_tilt_value + return { + "current_tilt_value": current_tilt_value, + "target_tilt_value": target_tilt_value, + } def values_to_watch( self, resolved_data: dict[str, Any] ) -> Iterable[ZwaveValue | None]: """Return list of all ZwaveValues resolved by helper that should be watched.""" - return [resolved_data["tilt_value"]] + return [resolved_data["current_tilt_value"], resolved_data["target_tilt_value"]] @staticmethod - def current_tilt_value( - resolved_data: dict[str, ZwaveValue | None] - ) -> ZwaveValue | None: + def current_tilt_value(resolved_data: dict[str, ZwaveValue]) -> ZwaveValue: """Get current tilt ZwaveValue from resolved data.""" - return resolved_data["tilt_value"] + return resolved_data["current_tilt_value"] + + @staticmethod + def target_tilt_value(resolved_data: dict[str, ZwaveValue]) -> ZwaveValue: + """Get target tilt ZwaveValue from resolved data.""" + return resolved_data["target_tilt_value"] @dataclass From 1f5916cb4db2cebb460d9bd73cbcc30bf0ab00c7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 24 May 2023 14:53:33 -0500 Subject: [PATCH 538/822] Update statistics tests to avoid patching utcnow (#93485) --- tests/components/statistics/test_sensor.py | 33 +++++++++++----------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/tests/components/statistics/test_sensor.py b/tests/components/statistics/test_sensor.py index 831ef91597de..3d6b4c6af454 100644 --- a/tests/components/statistics/test_sensor.py +++ b/tests/components/statistics/test_sensor.py @@ -7,6 +7,8 @@ import statistics from typing import Any from unittest.mock import patch +from freezegun import freeze_time + from homeassistant import config as hass_config from homeassistant.components.recorder import Recorder from homeassistant.components.sensor import ( @@ -380,16 +382,9 @@ async def test_sampling_size_1(hass: HomeAssistant) -> None: async def test_age_limit_expiry(hass: HomeAssistant) -> None: """Test that values are removed with given max age.""" now = dt_util.utcnow() - mock_data = { - "return_time": datetime(now.year + 1, 8, 2, 12, 23, tzinfo=dt_util.UTC) - } + current_time = datetime(now.year + 1, 8, 2, 12, 23, tzinfo=dt_util.UTC) - def mock_now(): - return mock_data["return_time"] - - with patch( - "homeassistant.components.statistics.sensor.dt_util.utcnow", new=mock_now - ): + with freeze_time(current_time) as freezer: assert await async_setup_component( hass, "sensor", @@ -409,8 +404,9 @@ async def test_age_limit_expiry(hass: HomeAssistant) -> None: await hass.async_block_till_done() for value in VALUES_NUMERIC: - mock_data["return_time"] += timedelta(minutes=1) - async_fire_time_changed(hass, mock_data["return_time"]) + current_time += timedelta(minutes=1) + freezer.move_to(current_time) + async_fire_time_changed(hass, current_time) hass.states.async_set( "sensor.test_monitored", str(value), @@ -429,8 +425,9 @@ async def test_age_limit_expiry(hass: HomeAssistant) -> None: # Values expire over time. Only two are left - mock_data["return_time"] += timedelta(minutes=3) - async_fire_time_changed(hass, mock_data["return_time"]) + current_time += timedelta(minutes=3) + freezer.move_to(current_time) + async_fire_time_changed(hass, current_time) await hass.async_block_till_done() state = hass.states.get("sensor.test") @@ -442,8 +439,9 @@ async def test_age_limit_expiry(hass: HomeAssistant) -> None: # Values expire over time. Only one is left - mock_data["return_time"] += timedelta(minutes=1) - async_fire_time_changed(hass, mock_data["return_time"]) + current_time += timedelta(minutes=1) + freezer.move_to(current_time) + async_fire_time_changed(hass, current_time) await hass.async_block_till_done() state = hass.states.get("sensor.test") @@ -455,8 +453,9 @@ async def test_age_limit_expiry(hass: HomeAssistant) -> None: # Values expire over time. Buffer is empty - mock_data["return_time"] += timedelta(minutes=1) - async_fire_time_changed(hass, mock_data["return_time"]) + current_time += timedelta(minutes=1) + freezer.move_to(current_time) + async_fire_time_changed(hass, current_time) await hass.async_block_till_done() state = hass.states.get("sensor.test") From 41aa3e223eccb0a55d699e6219215a11a45b2bdb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 24 May 2023 15:21:40 -0500 Subject: [PATCH 539/822] Update light tests to avoid patching utcnow (#93490) --- tests/components/light/test_device_condition.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/tests/components/light/test_device_condition.py b/tests/components/light/test_device_condition.py index 79c5d88c513d..9b6e38359fd1 100644 --- a/tests/components/light/test_device_condition.py +++ b/tests/components/light/test_device_condition.py @@ -1,7 +1,7 @@ """The test for light device automation.""" from datetime import timedelta -from unittest.mock import patch +from freezegun import freeze_time import pytest import homeassistant.components.automation as automation @@ -232,8 +232,7 @@ async def test_if_fires_on_for_condition( ent1, ent2, ent3 = platform.ENTITIES - with patch("homeassistant.core.dt_util.utcnow") as mock_utcnow: - mock_utcnow.return_value = point1 + with freeze_time(point1) as freezer: assert await async_setup_component( hass, automation.DOMAIN, @@ -271,7 +270,7 @@ async def test_if_fires_on_for_condition( assert len(calls) == 0 # Time travel 10 secs into the future - mock_utcnow.return_value = point2 + freezer.move_to(point2) hass.bus.async_fire("test_event1") await hass.async_block_till_done() assert len(calls) == 0 @@ -282,7 +281,7 @@ async def test_if_fires_on_for_condition( assert len(calls) == 0 # Time travel 20 secs into the future - mock_utcnow.return_value = point3 + freezer.move_to(point3) hass.bus.async_fire("test_event1") await hass.async_block_till_done() assert len(calls) == 1 From 33c2cb989529352da840e8ffce21d93acd7f79f1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 24 May 2023 15:24:15 -0500 Subject: [PATCH 540/822] Fix integration trapezoidal test (#93494) --- tests/components/integration/test_sensor.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/components/integration/test_sensor.py b/tests/components/integration/test_sensor.py index 7bbeeeb3e43c..515ae990debc 100644 --- a/tests/components/integration/test_sensor.py +++ b/tests/components/integration/test_sensor.py @@ -217,10 +217,11 @@ async def test_trapezoidal(hass: HomeAssistant) -> None: hass.states.async_set(entity_id, 0, {}) await hass.async_block_till_done() - # Testing a power sensor with non-monotonic intervals and values - for time, value in [(20, 10), (30, 30), (40, 5), (50, 0)]: - now = dt_util.utcnow() + timedelta(minutes=time) - with freeze_time(now): + start_time = dt_util.utcnow() + with freeze_time(start_time) as freezer: + # Testing a power sensor with non-monotonic intervals and values + for time, value in [(20, 10), (30, 30), (40, 5), (50, 0)]: + freezer.move_to(start_time + timedelta(minutes=time)) hass.states.async_set( entity_id, value, From bf2c12dcf2e605d0b5e0994e794260c2d9948c5d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 24 May 2023 15:28:22 -0500 Subject: [PATCH 541/822] Update remote tests to avoid patching utcnow (#93492) --- tests/components/remote/test_device_condition.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/tests/components/remote/test_device_condition.py b/tests/components/remote/test_device_condition.py index e20e86899507..30732c77fe31 100644 --- a/tests/components/remote/test_device_condition.py +++ b/tests/components/remote/test_device_condition.py @@ -1,7 +1,7 @@ """The test for remote device automation.""" from datetime import timedelta -from unittest.mock import patch +from freezegun import freeze_time import pytest import homeassistant.components.automation as automation @@ -232,8 +232,7 @@ async def test_if_fires_on_for_condition( ent1, ent2, ent3 = platform.ENTITIES - with patch("homeassistant.core.dt_util.utcnow") as mock_utcnow: - mock_utcnow.return_value = point1 + with freeze_time(point1) as freezer: assert await async_setup_component( hass, automation.DOMAIN, @@ -271,7 +270,7 @@ async def test_if_fires_on_for_condition( assert len(calls) == 0 # Time travel 10 secs into the future - mock_utcnow.return_value = point2 + freezer.move_to(point2) hass.bus.async_fire("test_event1") await hass.async_block_till_done() assert len(calls) == 0 @@ -282,7 +281,7 @@ async def test_if_fires_on_for_condition( assert len(calls) == 0 # Time travel 20 secs into the future - mock_utcnow.return_value = point3 + freezer.move_to(point3) hass.bus.async_fire("test_event1") await hass.async_block_till_done() assert len(calls) == 1 From 0160c8c14f9653dc2e3d9f9d68abd0ad1f2a2453 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 24 May 2023 15:28:38 -0500 Subject: [PATCH 542/822] Update switch tests to avoid patching utcnow (#93491) --- tests/components/switch/test_device_condition.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/tests/components/switch/test_device_condition.py b/tests/components/switch/test_device_condition.py index 6a1a269c4e1c..e15074b1f31e 100644 --- a/tests/components/switch/test_device_condition.py +++ b/tests/components/switch/test_device_condition.py @@ -1,7 +1,7 @@ """The test for switch device automation.""" from datetime import timedelta -from unittest.mock import patch +from freezegun import freeze_time import pytest import homeassistant.components.automation as automation @@ -232,8 +232,7 @@ async def test_if_fires_on_for_condition( ent1, ent2, ent3 = platform.ENTITIES - with patch("homeassistant.core.dt_util.utcnow") as mock_utcnow: - mock_utcnow.return_value = point1 + with freeze_time(point1) as freezer: assert await async_setup_component( hass, automation.DOMAIN, @@ -271,7 +270,7 @@ async def test_if_fires_on_for_condition( assert len(calls) == 0 # Time travel 10 secs into the future - mock_utcnow.return_value = point2 + freezer.move_to(point2) hass.bus.async_fire("test_event1") await hass.async_block_till_done() assert len(calls) == 0 @@ -282,7 +281,7 @@ async def test_if_fires_on_for_condition( assert len(calls) == 0 # Time travel 20 secs into the future - mock_utcnow.return_value = point3 + freezer.move_to(point3) hass.bus.async_fire("test_event1") await hass.async_block_till_done() assert len(calls) == 1 From 45b1ad36b1318b15910cdcdc615766513344eafc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 24 May 2023 15:38:54 -0500 Subject: [PATCH 543/822] Bump onvif-zeep-async to 3.1.8 (#93464) --- homeassistant/components/onvif/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/onvif/manifest.json b/homeassistant/components/onvif/manifest.json index a749e59be48c..fd00d28b8324 100644 --- a/homeassistant/components/onvif/manifest.json +++ b/homeassistant/components/onvif/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/onvif", "iot_class": "local_push", "loggers": ["onvif", "wsdiscovery", "zeep"], - "requirements": ["onvif-zeep-async==3.1.7", "WSDiscovery==2.0.0"] + "requirements": ["onvif-zeep-async==3.1.8", "WSDiscovery==2.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 62a047a525c6..e00cb6856eff 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1270,7 +1270,7 @@ ondilo==0.2.0 onkyo-eiscp==1.2.7 # homeassistant.components.onvif -onvif-zeep-async==3.1.7 +onvif-zeep-async==3.1.8 # homeassistant.components.opengarage open-garage==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 34dd029a7cf7..2c57541c588d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -960,7 +960,7 @@ omnilogic==0.4.5 ondilo==0.2.0 # homeassistant.components.onvif -onvif-zeep-async==3.1.7 +onvif-zeep-async==3.1.8 # homeassistant.components.opengarage open-garage==0.2.0 From 40a47c72f432d6bd74b30d1883feed16d1cdc9f0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 24 May 2023 16:09:26 -0500 Subject: [PATCH 544/822] Update history tests to avoid patching utcnow (#93487) --- tests/components/history/test_init.py | 39 +++++-------------- .../history/test_init_db_schema_30.py | 37 +++++------------- 2 files changed, 19 insertions(+), 57 deletions(-) diff --git a/tests/components/history/test_init.py b/tests/components/history/test_init.py index 30c84c56f001..043848342828 100644 --- a/tests/components/history/test_init.py +++ b/tests/components/history/test_init.py @@ -3,8 +3,9 @@ from datetime import timedelta from http import HTTPStatus import json -from unittest.mock import patch, sentinel +from unittest.mock import sentinel +from freezegun import freeze_time import pytest from homeassistant.components import history @@ -245,29 +246,18 @@ def test_get_significant_states_only(hass_history) -> None: points.append(start + timedelta(minutes=i)) states = [] - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start - ): + with freeze_time(start) as freezer: set_state("123", attributes={"attribute": 10.64}) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", - return_value=points[0], - ): + freezer.move_to(points[0]) # Attributes are different, state not states.append(set_state("123", attributes={"attribute": 21.42})) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", - return_value=points[1], - ): + freezer.move_to(points[1]) # state is different, attributes not states.append(set_state("32", attributes={"attribute": 21.42})) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", - return_value=points[2], - ): + freezer.move_to(points[2]) # everything is different states.append(set_state("412", attributes={"attribute": 54.23})) @@ -335,9 +325,7 @@ def record_states(hass): four = three + timedelta(seconds=1) states = {therm: [], therm2: [], mp: [], mp2: [], mp3: [], script_c: []} - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=one - ): + with freeze_time(one) as freezer: states[mp].append( set_state(mp, "idle", attributes={"media_title": str(sentinel.mt1)}) ) @@ -351,17 +339,12 @@ def record_states(hass): set_state(therm, 20, attributes={"current_temperature": 19.5}) ) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", - return_value=one + timedelta(microseconds=1), - ): + freezer.move_to(one + timedelta(microseconds=1)) states[mp].append( set_state(mp, "YouTube", attributes={"media_title": str(sentinel.mt2)}) ) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=two - ): + freezer.move_to(two) # This state will be skipped only different in time set_state(mp, "YouTube", attributes={"media_title": str(sentinel.mt3)}) # This state will be skipped because domain is excluded @@ -376,9 +359,7 @@ def record_states(hass): set_state(therm2, 20, attributes={"current_temperature": 19}) ) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=three - ): + freezer.move_to(three) states[mp].append( set_state(mp, "Netflix", attributes={"media_title": str(sentinel.mt4)}) ) diff --git a/tests/components/history/test_init_db_schema_30.py b/tests/components/history/test_init_db_schema_30.py index 7f3d8c76aed6..32358e95e410 100644 --- a/tests/components/history/test_init_db_schema_30.py +++ b/tests/components/history/test_init_db_schema_30.py @@ -7,6 +7,7 @@ from http import HTTPStatus import json from unittest.mock import patch, sentinel +from freezegun import freeze_time import pytest from homeassistant.components import recorder @@ -261,29 +262,18 @@ def test_get_significant_states_only(legacy_hass_history) -> None: points.append(start + timedelta(minutes=i)) states = [] - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start - ): + with freeze_time(start) as freezer: set_state("123", attributes={"attribute": 10.64}) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", - return_value=points[0], - ): + freezer.move_to(points[0]) # Attributes are different, state not states.append(set_state("123", attributes={"attribute": 21.42})) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", - return_value=points[1], - ): + freezer.move_to(points[1]) # state is different, attributes not states.append(set_state("32", attributes={"attribute": 21.42})) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", - return_value=points[2], - ): + freezer.move_to(points[2]) # everything is different states.append(set_state("412", attributes={"attribute": 54.23})) @@ -351,9 +341,7 @@ def record_states(hass): four = three + timedelta(seconds=1) states = {therm: [], therm2: [], mp: [], mp2: [], mp3: [], script_c: []} - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=one - ): + with freeze_time(one) as freezer: states[mp].append( set_state(mp, "idle", attributes={"media_title": str(sentinel.mt1)}) ) @@ -367,17 +355,12 @@ def record_states(hass): set_state(therm, 20, attributes={"current_temperature": 19.5}) ) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", - return_value=one + timedelta(microseconds=1), - ): + freezer.move_to(one + timedelta(microseconds=1)) states[mp].append( set_state(mp, "YouTube", attributes={"media_title": str(sentinel.mt2)}) ) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=two - ): + freezer.move_to(two) # This state will be skipped only different in time set_state(mp, "YouTube", attributes={"media_title": str(sentinel.mt3)}) # This state will be skipped because domain is excluded @@ -392,9 +375,7 @@ def record_states(hass): set_state(therm2, 20, attributes={"current_temperature": 19}) ) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=three - ): + freezer.move_to(three) states[mp].append( set_state(mp, "Netflix", attributes={"media_title": str(sentinel.mt4)}) ) From ee62584570b93ca3d446c129fce8dd04b2d493ba Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 24 May 2023 17:03:37 -0500 Subject: [PATCH 545/822] Add constraint for pyminiaudio to 1.57 (#93501) --- 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 45ca278e0527..151f8abef986 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -175,3 +175,7 @@ pysnmplib==5.0.21 # pysnmp is no longer maintained and does not work with newer # python pysnmp==1000000000.0.0 + +# pyminiaudio 1.58 is missing files in the package +# https://github.com/irmen/pyminiaudio/issues/67 +miniaudio==1.57 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 43aff2566fc7..45009dedc7cf 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -179,6 +179,10 @@ pysnmplib==5.0.21 # pysnmp is no longer maintained and does not work with newer # python pysnmp==1000000000.0.0 + +# pyminiaudio 1.58 is missing files in the package +# https://github.com/irmen/pyminiaudio/issues/67 +miniaudio==1.57 """ IGNORE_PRE_COMMIT_HOOK_ID = ( From f5eb872eaf445b762931c803598ab691dc2b1c34 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 24 May 2023 17:38:09 -0500 Subject: [PATCH 546/822] Update state helper test to avoid patching utcnow (#93484) --- tests/helpers/test_state.py | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/tests/helpers/test_state.py b/tests/helpers/test_state.py index 995305e48d19..1919586daa30 100644 --- a/tests/helpers/test_state.py +++ b/tests/helpers/test_state.py @@ -3,6 +3,7 @@ import asyncio from datetime import timedelta from unittest.mock import Mock, patch +from freezegun import freeze_time import pytest from homeassistant.components.sun import STATE_ABOVE_HORIZON, STATE_BELOW_HORIZON @@ -33,20 +34,17 @@ async def test_async_track_states( point2 = point1 + timedelta(seconds=5) point3 = point2 + timedelta(seconds=5) - with patch("homeassistant.core.dt_util.utcnow") as mock_utcnow: - mock_utcnow.return_value = point2 + with freeze_time(point2) as freezer, state.AsyncTrackStates(hass) as states: + freezer.move_to(point1) + hass.states.async_set("light.test", "on") - with state.AsyncTrackStates(hass) as states: - mock_utcnow.return_value = point1 - hass.states.async_set("light.test", "on") + freezer.move_to(point2) + hass.states.async_set("light.test2", "on") + state2 = hass.states.get("light.test2") - mock_utcnow.return_value = point2 - hass.states.async_set("light.test2", "on") - state2 = hass.states.get("light.test2") - - mock_utcnow.return_value = point3 - hass.states.async_set("light.test3", "on") - state3 = hass.states.get("light.test3") + freezer.move_to(point3) + hass.states.async_set("light.test3", "on") + state3 = hass.states.get("light.test3") assert [state2, state3] == sorted(states, key=lambda state: state.entity_id) @@ -92,15 +90,15 @@ async def test_get_changed_since( point2 = point1 + timedelta(seconds=5) point3 = point2 + timedelta(seconds=5) - with patch("homeassistant.core.dt_util.utcnow", return_value=point1): + with freeze_time(point1) as freezer: hass.states.async_set("light.test", "on") state1 = hass.states.get("light.test") - with patch("homeassistant.core.dt_util.utcnow", return_value=point2): + freezer.move_to(point2) hass.states.async_set("light.test2", "on") state2 = hass.states.get("light.test2") - with patch("homeassistant.core.dt_util.utcnow", return_value=point3): + freezer.move_to(point3) hass.states.async_set("light.test3", "on") state3 = hass.states.get("light.test3") From f8d8f5cabac197d18b55185aaa4940558a78e657 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 24 May 2023 17:38:38 -0500 Subject: [PATCH 547/822] Update statistics tests to avoid patching utcnow (#93499) --- tests/components/statistics/test_sensor.py | 44 ++++++++++------------ 1 file changed, 19 insertions(+), 25 deletions(-) diff --git a/tests/components/statistics/test_sensor.py b/tests/components/statistics/test_sensor.py index 3d6b4c6af454..4b77e2d07253 100644 --- a/tests/components/statistics/test_sensor.py +++ b/tests/components/statistics/test_sensor.py @@ -784,12 +784,8 @@ async def test_unitless_source_sensor(hass: HomeAssistant) -> None: async def test_state_characteristics(hass: HomeAssistant) -> None: """Test configured state characteristic for value and unit.""" now = dt_util.utcnow() + current_time = datetime(now.year + 1, 8, 2, 12, 23, 42, tzinfo=dt_util.UTC) start_datetime = datetime(now.year + 1, 8, 2, 12, 23, 42, tzinfo=dt_util.UTC) - mock_data = {"return_time": start_datetime} - - def mock_now(): - return mock_data["return_time"] - characteristics: Sequence[dict[str, Any]] = ( { "source_sensor_domain": "sensor", @@ -1118,9 +1114,7 @@ async def test_state_characteristics(hass: HomeAssistant) -> None: } ) - with patch( - "homeassistant.components.statistics.sensor.dt_util.utcnow", new=mock_now - ): + with freeze_time(current_time) as freezer: assert await async_setup_component( hass, "sensor", @@ -1131,8 +1125,9 @@ async def test_state_characteristics(hass: HomeAssistant) -> None: # With all values in buffer for i in range(len(VALUES_NUMERIC)): - mock_data["return_time"] += timedelta(minutes=1) - async_fire_time_changed(hass, mock_data["return_time"]) + current_time += timedelta(minutes=1) + freezer.move_to(current_time) + async_fire_time_changed(hass, current_time) hass.states.async_set( "sensor.test_monitored", str(VALUES_NUMERIC[i]), @@ -1166,8 +1161,9 @@ async def test_state_characteristics(hass: HomeAssistant) -> None: # With single value in buffer - mock_data["return_time"] += timedelta(minutes=8) - async_fire_time_changed(hass, mock_data["return_time"]) + current_time += timedelta(minutes=8) + freezer.move_to(current_time) + async_fire_time_changed(hass, current_time) await hass.async_block_till_done() for characteristic in characteristics: @@ -1188,8 +1184,9 @@ async def test_state_characteristics(hass: HomeAssistant) -> None: # With empty buffer - mock_data["return_time"] += timedelta(minutes=1) - async_fire_time_changed(hass, mock_data["return_time"]) + current_time += timedelta(minutes=1) + freezer.move_to(current_time) + async_fire_time_changed(hass, current_time) await hass.async_block_till_done() for characteristic in characteristics: @@ -1294,12 +1291,7 @@ async def test_initialize_from_database_with_maxage( ) -> None: """Test initializing the statistics from the database.""" now = dt_util.utcnow() - mock_data = { - "return_time": datetime(now.year + 1, 8, 2, 12, 23, 42, tzinfo=dt_util.UTC) - } - - def mock_now(): - return mock_data["return_time"] + current_time = datetime(now.year + 1, 8, 2, 12, 23, 42, tzinfo=dt_util.UTC) # Testing correct retrieval from recorder, thus we do not # want purging to occur within the class itself. @@ -1310,9 +1302,9 @@ async def test_initialize_from_database_with_maxage( await hass.async_block_till_done() await async_wait_recording_done(hass) - with patch( - "homeassistant.components.statistics.sensor.dt_util.utcnow", new=mock_now - ), patch.object(StatisticsSensor, "_purge_old_states", mock_purge): + with freeze_time(current_time) as freezer, patch.object( + StatisticsSensor, "_purge_old_states", mock_purge + ): for value in VALUES_NUMERIC: hass.states.async_set( "sensor.test_monitored", @@ -1320,7 +1312,9 @@ async def test_initialize_from_database_with_maxage( {ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS}, ) await hass.async_block_till_done() - mock_data["return_time"] += timedelta(hours=1) + current_time += timedelta(hours=1) + freezer.move_to(current_time) + await async_wait_recording_done(hass) # create the statistics component, get filled from database assert await async_setup_component( @@ -1346,7 +1340,7 @@ async def test_initialize_from_database_with_maxage( assert state.attributes.get("age_coverage_ratio") == round(2 / 3, 2) # The max_age timestamp should be 1 hour before what we have right # now in mock_data['return_time']. - assert mock_data["return_time"] == datetime.strptime( + assert current_time == datetime.strptime( state.state, "%Y-%m-%dT%H:%M:%S%z" ) + timedelta(hours=1) From 9dcd73b30895d5134e173c20831b471de8f71a19 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 24 May 2023 17:47:42 -0500 Subject: [PATCH 548/822] Update recorder tests to avoid patching utcnow (#93489) --- tests/components/recorder/common.py | 18 +-- tests/components/recorder/test_history.py | 112 +++++------------- .../recorder/test_history_db_schema_30.py | 99 +++++----------- .../recorder/test_history_db_schema_32.py | 98 +++++---------- tests/components/recorder/test_purge.py | 5 +- .../recorder/test_purge_v32_schema.py | 5 +- 6 files changed, 93 insertions(+), 244 deletions(-) diff --git a/tests/components/recorder/common.py b/tests/components/recorder/common.py index e017aa384f78..521be81c89b6 100644 --- a/tests/components/recorder/common.py +++ b/tests/components/recorder/common.py @@ -13,6 +13,7 @@ import time from typing import Any, Literal, cast from unittest.mock import patch, sentinel +from freezegun import freeze_time from sqlalchemy import create_engine from sqlalchemy.orm.session import Session @@ -282,9 +283,7 @@ def record_states(hass): four = three + timedelta(seconds=15 * 5) states = {mp: [], sns1: [], sns2: [], sns3: [], sns4: []} - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=one - ): + with freeze_time(one) as freezer: states[mp].append( set_state(mp, "idle", attributes={"media_title": str(sentinel.mt1)}) ) @@ -293,25 +292,18 @@ def record_states(hass): states[sns3].append(set_state(sns3, "10", attributes=sns3_attr)) states[sns4].append(set_state(sns4, "10", attributes=sns4_attr)) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", - return_value=one + timedelta(microseconds=1), - ): + freezer.move_to(one + timedelta(microseconds=1)) states[mp].append( set_state(mp, "YouTube", attributes={"media_title": str(sentinel.mt2)}) ) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=two - ): + freezer.move_to(two) states[sns1].append(set_state(sns1, "15", attributes=sns1_attr)) states[sns2].append(set_state(sns2, "15", attributes=sns2_attr)) states[sns3].append(set_state(sns3, "15", attributes=sns3_attr)) states[sns4].append(set_state(sns4, "15", attributes=sns4_attr)) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=three - ): + freezer.move_to(three) states[sns1].append(set_state(sns1, "20", attributes=sns1_attr)) states[sns2].append(set_state(sns2, "20", attributes=sns2_attr)) states[sns3].append(set_state(sns3, "20", attributes=sns3_attr)) diff --git a/tests/components/recorder/test_history.py b/tests/components/recorder/test_history.py index b9c44f486b38..be77f2907d6f 100644 --- a/tests/components/recorder/test_history.py +++ b/tests/components/recorder/test_history.py @@ -9,6 +9,7 @@ from datetime import datetime, timedelta import json from unittest.mock import patch, sentinel +from freezegun import freeze_time import pytest from sqlalchemy import text @@ -223,15 +224,11 @@ def test_state_changes_during_period( point = start + timedelta(seconds=1) end = point + timedelta(seconds=1) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start - ): + with freeze_time(start) as freezer: set_state("idle") set_state("YouTube") - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point - ): + freezer.move_to(point) states = [ set_state("idle"), set_state("Netflix"), @@ -239,9 +236,7 @@ def test_state_changes_during_period( set_state("YouTube"), ] - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=end - ): + freezer.move_to(end) set_state("Netflix") set_state("Plex") @@ -272,32 +267,23 @@ def test_state_changes_during_period_descending( point4 = start + timedelta(seconds=1, microseconds=300) end = point + timedelta(seconds=1, microseconds=400) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start - ): + with freeze_time(start) as freezer: set_state("idle") set_state("YouTube") - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point - ): + freezer.move_to(point) states = [set_state("idle")] - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point2 - ): + + freezer.move_to(point2) states.append(set_state("Netflix")) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point3 - ): + + freezer.move_to(point3) states.append(set_state("Plex")) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point4 - ): + + freezer.move_to(point4) states.append(set_state("YouTube")) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=end - ): + freezer.move_to(end) set_state("Netflix") set_state("Plex") @@ -379,21 +365,15 @@ def test_get_last_state_changes(hass_recorder: Callable[..., HomeAssistant]) -> start = dt_util.utcnow() - timedelta(minutes=2) point = start + timedelta(minutes=1) point2 = point + timedelta(minutes=1, seconds=1) + states = [] - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start - ): + with freeze_time(start) as freezer: set_state("1") - states = [] - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point - ): + freezer.move_to(point) states.append(set_state("2")) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point2 - ): + freezer.move_to(point2) states.append(set_state("3")) hist = history.get_last_state_changes(hass, 2, entity_id) @@ -415,21 +395,15 @@ def test_get_last_state_change(hass_recorder: Callable[..., HomeAssistant]) -> N start = dt_util.utcnow() - timedelta(minutes=2) point = start + timedelta(minutes=1) point2 = point + timedelta(minutes=1, seconds=1) + states = [] - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start - ): + with freeze_time(start) as freezer: set_state("1") - states = [] - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point - ): + freezer.move_to(point) set_state("2") - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point2 - ): + freezer.move_to(point2) states.append(set_state("3")) hist = history.get_last_state_changes(hass, 1, entity_id) @@ -457,14 +431,10 @@ def test_ensure_state_can_be_copied( start = dt_util.utcnow() - timedelta(minutes=2) point = start + timedelta(minutes=1) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start - ): + with freeze_time(start) as freezer: set_state("1") - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point - ): + freezer.move_to(point) set_state("2") hist = history.get_last_state_changes(hass, 2, entity_id) @@ -694,29 +664,18 @@ def test_get_significant_states_only( points.append(start + timedelta(minutes=i)) states = [] - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start - ): + with freeze_time(start) as freezer: set_state("123", attributes={"attribute": 10.64}) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", - return_value=points[0], - ): + freezer.move_to(points[0]) # Attributes are different, state not states.append(set_state("123", attributes={"attribute": 21.42})) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", - return_value=points[1], - ): + freezer.move_to(points[1]) # state is different, attributes not states.append(set_state("32", attributes={"attribute": 21.42})) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", - return_value=points[2], - ): + freezer.move_to(points[2]) # everything is different states.append(set_state("412", attributes={"attribute": 54.23})) @@ -805,9 +764,7 @@ def record_states(hass) -> tuple[datetime, datetime, dict[str, list[State]]]: four = three + timedelta(seconds=1) states = {therm: [], therm2: [], mp: [], mp2: [], mp3: [], script_c: []} - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=one - ): + with freeze_time(one) as freezer: states[mp].append( set_state(mp, "idle", attributes={"media_title": str(sentinel.mt1)}) ) @@ -821,17 +778,12 @@ def record_states(hass) -> tuple[datetime, datetime, dict[str, list[State]]]: set_state(therm, 20, attributes={"current_temperature": 19.5}) ) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", - return_value=one + timedelta(microseconds=1), - ): + freezer.move_to(one + timedelta(microseconds=1)) states[mp].append( set_state(mp, "YouTube", attributes={"media_title": str(sentinel.mt2)}) ) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=two - ): + freezer.move_to(two) # This state will be skipped only different in time set_state(mp, "YouTube", attributes={"media_title": str(sentinel.mt3)}) # This state will be skipped because domain is excluded @@ -846,9 +798,7 @@ def record_states(hass) -> tuple[datetime, datetime, dict[str, list[State]]]: set_state(therm2, 20, attributes={"current_temperature": 19}) ) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=three - ): + freezer.move_to(three) states[mp].append( set_state(mp, "Netflix", attributes={"media_title": str(sentinel.mt4)}) ) diff --git a/tests/components/recorder/test_history_db_schema_30.py b/tests/components/recorder/test_history_db_schema_30.py index b04d172487cc..30d8de654d7e 100644 --- a/tests/components/recorder/test_history_db_schema_30.py +++ b/tests/components/recorder/test_history_db_schema_30.py @@ -9,6 +9,7 @@ from datetime import datetime, timedelta import json from unittest.mock import patch, sentinel +from freezegun import freeze_time import pytest from homeassistant.components import recorder @@ -129,15 +130,11 @@ def test_state_changes_during_period( point = start + timedelta(seconds=1) end = point + timedelta(seconds=1) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start - ): + with freeze_time(start) as freezer: set_state("idle") set_state("YouTube") - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point - ): + freezer.move_to(point) states = [ set_state("idle"), set_state("Netflix"), @@ -145,9 +142,7 @@ def test_state_changes_during_period( set_state("YouTube"), ] - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=end - ): + freezer.move_to(end) set_state("Netflix") set_state("Plex") @@ -180,32 +175,24 @@ def test_state_changes_during_period_descending( point4 = start + timedelta(seconds=1, microseconds=4) end = point + timedelta(seconds=1) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start - ): + with freeze_time(start) as freezer: set_state("idle") set_state("YouTube") - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point - ): + freezer.move_to(point) + states = [set_state("idle")] - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point2 - ): + freezer.move_to(point2) + states.append(set_state("Netflix")) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point3 - ): + + freezer.move_to(point3) states.append(set_state("Plex")) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point4 - ): + + freezer.move_to(point4) states.append(set_state("YouTube")) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=end - ): + freezer.move_to(end) set_state("Netflix") set_state("Plex") @@ -238,21 +225,15 @@ def test_get_last_state_changes(hass_recorder: Callable[..., HomeAssistant]) -> start = dt_util.utcnow() - timedelta(minutes=2) point = start + timedelta(minutes=1) point2 = point + timedelta(minutes=1, seconds=1) + states = [] - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start - ): + with freeze_time(start) as freezer: set_state("1") - states = [] - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point - ): + freezer.move_to(point) states.append(set_state("2")) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point2 - ): + freezer.move_to(point2) states.append(set_state("3")) hist = history.get_last_state_changes(hass, 2, entity_id) @@ -282,14 +263,10 @@ def test_ensure_state_can_be_copied( start = dt_util.utcnow() - timedelta(minutes=2) point = start + timedelta(minutes=1) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start - ): + with freeze_time(start) as freezer: set_state("1") - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point - ): + freezer.move_to(point) set_state("2") hist = history.get_last_state_changes(hass, 2, entity_id) @@ -546,29 +523,18 @@ def test_get_significant_states_only( points.append(start + timedelta(minutes=i)) states = [] - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start - ): + with freeze_time(start) as freezer: set_state("123", attributes={"attribute": 10.64}) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", - return_value=points[0], - ): + freezer.move_to(points[0]) # Attributes are different, state not states.append(set_state("123", attributes={"attribute": 21.42})) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", - return_value=points[1], - ): + freezer.move_to(points[1]) # state is different, attributes not states.append(set_state("32", attributes={"attribute": 21.42})) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", - return_value=points[2], - ): + freezer.move_to(points[2]) # everything is different states.append(set_state("412", attributes={"attribute": 54.23})) @@ -630,9 +596,7 @@ def record_states(hass) -> tuple[datetime, datetime, dict[str, list[State]]]: four = three + timedelta(seconds=1) states = {therm: [], therm2: [], mp: [], mp2: [], mp3: [], script_c: []} - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=one - ): + with freeze_time(one) as freezer: states[mp].append( set_state(mp, "idle", attributes={"media_title": str(sentinel.mt1)}) ) @@ -646,17 +610,12 @@ def record_states(hass) -> tuple[datetime, datetime, dict[str, list[State]]]: set_state(therm, 20, attributes={"current_temperature": 19.5}) ) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", - return_value=one + timedelta(microseconds=1), - ): + freezer.move_to(one + timedelta(microseconds=1)) states[mp].append( set_state(mp, "YouTube", attributes={"media_title": str(sentinel.mt2)}) ) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=two - ): + freezer.move_to(two) # This state will be skipped only different in time set_state(mp, "YouTube", attributes={"media_title": str(sentinel.mt3)}) # This state will be skipped because domain is excluded @@ -671,9 +630,7 @@ def record_states(hass) -> tuple[datetime, datetime, dict[str, list[State]]]: set_state(therm2, 20, attributes={"current_temperature": 19}) ) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=three - ): + freezer.move_to(three) states[mp].append( set_state(mp, "Netflix", attributes={"media_title": str(sentinel.mt4)}) ) diff --git a/tests/components/recorder/test_history_db_schema_32.py b/tests/components/recorder/test_history_db_schema_32.py index abc80572c16f..51e4bfdc4027 100644 --- a/tests/components/recorder/test_history_db_schema_32.py +++ b/tests/components/recorder/test_history_db_schema_32.py @@ -9,6 +9,7 @@ from datetime import datetime, timedelta import json from unittest.mock import patch, sentinel +from freezegun import freeze_time import pytest from homeassistant.components import recorder @@ -129,15 +130,11 @@ def test_state_changes_during_period( point = start + timedelta(seconds=1) end = point + timedelta(seconds=1) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start - ): + with freeze_time(start) as freezer: set_state("idle") set_state("YouTube") - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point - ): + freezer.move_to(point) states = [ set_state("idle"), set_state("Netflix"), @@ -145,9 +142,7 @@ def test_state_changes_during_period( set_state("YouTube"), ] - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=end - ): + freezer.move_to(end) set_state("Netflix") set_state("Plex") @@ -180,32 +175,23 @@ def test_state_changes_during_period_descending( point4 = start + timedelta(seconds=1, microseconds=4) end = point + timedelta(seconds=1) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start - ): + with freeze_time(start) as freezer: set_state("idle") set_state("YouTube") - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point - ): + freezer.move_to(point) states = [set_state("idle")] - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point2 - ): + + freezer.move_to(point2) states.append(set_state("Netflix")) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point3 - ): + + freezer.move_to(point3) states.append(set_state("Plex")) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point4 - ): + + freezer.move_to(point4) states.append(set_state("YouTube")) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=end - ): + freezer.move_to(end) set_state("Netflix") set_state("Plex") @@ -238,21 +224,15 @@ def test_get_last_state_changes(hass_recorder: Callable[..., HomeAssistant]) -> start = dt_util.utcnow() - timedelta(minutes=2) point = start + timedelta(minutes=1) point2 = point + timedelta(minutes=1, seconds=1) + states = [] - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start - ): + with freeze_time(start) as freezer: set_state("1") - states = [] - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point - ): + freezer.move_to(point) states.append(set_state("2")) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point2 - ): + freezer.move_to(point2) states.append(set_state("3")) hist = history.get_last_state_changes(hass, 2, entity_id) @@ -282,14 +262,10 @@ def test_ensure_state_can_be_copied( start = dt_util.utcnow() - timedelta(minutes=2) point = start + timedelta(minutes=1) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start - ): + with freeze_time(start) as freezer: set_state("1") - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point - ): + freezer.move_to(point) set_state("2") hist = history.get_last_state_changes(hass, 2, entity_id) @@ -537,29 +513,18 @@ def test_get_significant_states_only( points.append(start + timedelta(minutes=i)) states = [] - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start - ): + with freeze_time(start) as freezer: set_state("123", attributes={"attribute": 10.64}) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", - return_value=points[0], - ): + freezer.move_to(points[0]) # Attributes are different, state not states.append(set_state("123", attributes={"attribute": 21.42})) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", - return_value=points[1], - ): + freezer.move_to(points[1]) # state is different, attributes not states.append(set_state("32", attributes={"attribute": 21.42})) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", - return_value=points[2], - ): + freezer.move_to(points[2]) # everything is different states.append(set_state("412", attributes={"attribute": 54.23})) @@ -621,9 +586,7 @@ def record_states(hass) -> tuple[datetime, datetime, dict[str, list[State]]]: four = three + timedelta(seconds=1) states = {therm: [], therm2: [], mp: [], mp2: [], mp3: [], script_c: []} - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=one - ): + with freeze_time(one) as freezer: states[mp].append( set_state(mp, "idle", attributes={"media_title": str(sentinel.mt1)}) ) @@ -637,17 +600,12 @@ def record_states(hass) -> tuple[datetime, datetime, dict[str, list[State]]]: set_state(therm, 20, attributes={"current_temperature": 19.5}) ) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", - return_value=one + timedelta(microseconds=1), - ): + freezer.move_to(one + timedelta(microseconds=1)) states[mp].append( set_state(mp, "YouTube", attributes={"media_title": str(sentinel.mt2)}) ) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=two - ): + freezer.move_to(two) # This state will be skipped only different in time set_state(mp, "YouTube", attributes={"media_title": str(sentinel.mt3)}) # This state will be skipped because domain is excluded @@ -662,9 +620,7 @@ def record_states(hass) -> tuple[datetime, datetime, dict[str, list[State]]]: set_state(therm2, 20, attributes={"current_temperature": 19}) ) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=three - ): + freezer.move_to(three) states[mp].append( set_state(mp, "Netflix", attributes={"media_title": str(sentinel.mt4)}) ) diff --git a/tests/components/recorder/test_purge.py b/tests/components/recorder/test_purge.py index 61f2d57a081c..fadcc688753c 100644 --- a/tests/components/recorder/test_purge.py +++ b/tests/components/recorder/test_purge.py @@ -1438,10 +1438,7 @@ async def _add_test_states(hass: HomeAssistant): state = f"dontpurgeme_{event_id}" attributes = {"dontpurgeme": True, **base_attributes} - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", - return_value=timestamp, - ): + with freeze_time(timestamp): await set_state("test.recorder2", state, attributes=attributes) diff --git a/tests/components/recorder/test_purge_v32_schema.py b/tests/components/recorder/test_purge_v32_schema.py index 613c17b3d39a..18c35e8eb81b 100644 --- a/tests/components/recorder/test_purge_v32_schema.py +++ b/tests/components/recorder/test_purge_v32_schema.py @@ -733,10 +733,7 @@ async def _add_test_states(hass: HomeAssistant): state = f"dontpurgeme_{event_id}" attributes = {"dontpurgeme": True, **base_attributes} - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", - return_value=timestamp, - ): + with freeze_time(timestamp): await set_state("test.recorder2", state, attributes=attributes) From d5e09bd4c33d799a4d7eb1011f2950cdf0f3db74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Modzelewski?= Date: Thu, 25 May 2023 01:28:29 +0200 Subject: [PATCH 549/822] Support volume level push updates in apple_tv integration (#93496) --- homeassistant/components/apple_tv/manifest.json | 2 +- homeassistant/components/apple_tv/media_player.py | 8 ++++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/apple_tv/manifest.json b/homeassistant/components/apple_tv/manifest.json index c534c635317c..2d2df955441a 100644 --- a/homeassistant/components/apple_tv/manifest.json +++ b/homeassistant/components/apple_tv/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/apple_tv", "iot_class": "local_push", "loggers": ["pyatv", "srptools"], - "requirements": ["pyatv==0.11.0"], + "requirements": ["pyatv==0.12.0"], "zeroconf": [ "_mediaremotetv._tcp.local.", "_companion-link._tcp.local.", diff --git a/homeassistant/components/apple_tv/media_player.py b/homeassistant/components/apple_tv/media_player.py index 06618e4f2a35..4196dd1bd9a9 100644 --- a/homeassistant/components/apple_tv/media_player.py +++ b/homeassistant/components/apple_tv/media_player.py @@ -138,6 +138,9 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity): # Listen to power updates self.atv.power.listener = self + # Listen to volume updates + self.atv.audio.listener = self + if self.atv.features.in_state(FeatureState.Available, FeatureName.AppList): self.hass.create_task(self._update_app_list()) @@ -203,6 +206,11 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity): """Update power state when it changes.""" self.async_write_ha_state() + @callback + def volume_update(self, old_level: float, new_level: float) -> None: + """Update volume when it changes.""" + self.async_write_ha_state() + @property def app_id(self) -> str | None: """ID of the current running app.""" diff --git a/requirements_all.txt b/requirements_all.txt index e00cb6856eff..3fa15da8b473 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1518,7 +1518,7 @@ pyatmo==7.5.0 pyatome==0.1.1 # homeassistant.components.apple_tv -pyatv==0.11.0 +pyatv==0.12.0 # homeassistant.components.aussie_broadband pyaussiebb==0.0.15 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2c57541c588d..ef90db4f5d4d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1130,7 +1130,7 @@ pyatag==0.3.5.3 pyatmo==7.5.0 # homeassistant.components.apple_tv -pyatv==0.11.0 +pyatv==0.12.0 # homeassistant.components.aussie_broadband pyaussiebb==0.0.15 From 7ec6e03d5d9a5c80c93d5f9e51e788a7108585c5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 24 May 2023 19:54:08 -0500 Subject: [PATCH 550/822] Update sensor tests to avoid patching utcnow (#93497) --- tests/components/sensor/test_recorder.py | 653 ++++++++++++----------- 1 file changed, 342 insertions(+), 311 deletions(-) diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 53fb2493d706..65b0a0b94856 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -7,6 +7,8 @@ import math from statistics import mean from unittest.mock import patch +from freezegun import freeze_time +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant import loader @@ -155,7 +157,8 @@ def test_compile_hourly_statistics( "state_class": "measurement", "unit_of_measurement": state_unit, } - four, states = record_states(hass, zero, "sensor.test1", attributes) + with freeze_time(zero) as freezer: + four, states = record_states(hass, freezer, zero, "sensor.test1", attributes) hist = history.get_significant_states( hass, zero, four, hass.states.async_entity_ids() ) @@ -251,17 +254,13 @@ def test_compile_hourly_statistics_with_some_same_last_updated( four = three + timedelta(seconds=10 * 5) states = {entity_id: []} - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=one - ): + with freeze_time(one) as freezer: states[entity_id].append( set_state(entity_id, str(seq[0]), attributes=attributes) ) - # Record two states at the exact same time - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=two - ): + # Record two states at the exact same time + freezer.move_to(two) states[entity_id].append( set_state(entity_id, str(seq[1]), attributes=attributes) ) @@ -269,9 +268,7 @@ def test_compile_hourly_statistics_with_some_same_last_updated( set_state(entity_id, str(seq[2]), attributes=attributes) ) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=three - ): + freezer.move_to(three) states[entity_id].append( set_state(entity_id, str(seq[3]), attributes=attributes) ) @@ -371,9 +368,7 @@ def test_compile_hourly_statistics_with_all_same_last_updated( four = three + timedelta(seconds=10 * 5) states = {entity_id: []} - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=two - ): + with freeze_time(two): states[entity_id].append( set_state(entity_id, str(seq[0]), attributes=attributes) ) @@ -480,9 +475,7 @@ def test_compile_hourly_statistics_only_state_is_and_end_of_period( end = zero + timedelta(minutes=5) states = {entity_id: []} - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=end - ): + with freeze_time(end): states[entity_id].append( set_state(entity_id, str(seq[0]), attributes=attributes) ) @@ -559,7 +552,8 @@ def test_compile_hourly_statistics_purged_state_changes( "state_class": "measurement", "unit_of_measurement": state_unit, } - four, states = record_states(hass, zero, "sensor.test1", attributes) + with freeze_time(zero) as freezer: + four, states = record_states(hass, freezer, zero, "sensor.test1", attributes) hist = history.get_significant_states( hass, zero, four, hass.states.async_entity_ids() ) @@ -568,9 +562,7 @@ def test_compile_hourly_statistics_purged_state_changes( mean = min = max = float(hist["sensor.test1"][-1].state) # Purge all states from the database - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=four - ): + with freeze_time(four): hass.services.call("recorder", "purge", {"keep_days": 0}) hass.block_till_done() wait_recording_done(hass) @@ -623,31 +615,32 @@ def test_compile_hourly_statistics_wrong_unit( hass = hass_recorder() setup_component(hass, "sensor", {}) wait_recording_done(hass) # Wait for the sensor recorder platform to be added - four, states = record_states(hass, zero, "sensor.test1", attributes) + with freeze_time(zero) as freezer: + four, states = record_states(hass, freezer, zero, "sensor.test1", attributes) - attributes_tmp = dict(attributes) - attributes_tmp["unit_of_measurement"] = "invalid" - _, _states = record_states(hass, zero, "sensor.test2", attributes_tmp) - states = {**states, **_states} - attributes_tmp.pop("unit_of_measurement") - _, _states = record_states(hass, zero, "sensor.test3", attributes_tmp) - states = {**states, **_states} + attributes_tmp = dict(attributes) + attributes_tmp["unit_of_measurement"] = "invalid" + _, _states = record_states(hass, freezer, zero, "sensor.test2", attributes_tmp) + states = {**states, **_states} + attributes_tmp.pop("unit_of_measurement") + _, _states = record_states(hass, freezer, zero, "sensor.test3", attributes_tmp) + states = {**states, **_states} - attributes_tmp = dict(attributes) - attributes_tmp["state_class"] = "invalid" - _, _states = record_states(hass, zero, "sensor.test4", attributes_tmp) - states = {**states, **_states} - attributes_tmp.pop("state_class") - _, _states = record_states(hass, zero, "sensor.test5", attributes_tmp) - states = {**states, **_states} + attributes_tmp = dict(attributes) + attributes_tmp["state_class"] = "invalid" + _, _states = record_states(hass, freezer, zero, "sensor.test4", attributes_tmp) + states = {**states, **_states} + attributes_tmp.pop("state_class") + _, _states = record_states(hass, freezer, zero, "sensor.test5", attributes_tmp) + states = {**states, **_states} - attributes_tmp = dict(attributes) - attributes_tmp["device_class"] = "invalid" - _, _states = record_states(hass, zero, "sensor.test6", attributes_tmp) - states = {**states, **_states} - attributes_tmp.pop("device_class") - _, _states = record_states(hass, zero, "sensor.test7", attributes_tmp) - states = {**states, **_states} + attributes_tmp = dict(attributes) + attributes_tmp["device_class"] = "invalid" + _, _states = record_states(hass, freezer, zero, "sensor.test6", attributes_tmp) + states = {**states, **_states} + attributes_tmp.pop("device_class") + _, _states = record_states(hass, freezer, zero, "sensor.test7", attributes_tmp) + states = {**states, **_states} hist = history.get_significant_states( hass, zero, four, hass.states.async_entity_ids() @@ -832,7 +825,6 @@ async def test_compile_hourly_sum_statistics_amount( period0_end = period1 = period0 + timedelta(minutes=5) period1_end = period2 = period0 + timedelta(minutes=10) period2_end = period0 + timedelta(minutes=15) - client = await hass_ws_client() hass.config.units = units await async_setup_component(hass, "sensor", {}) # Wait for the sensor recorder platform to be added @@ -844,10 +836,10 @@ async def test_compile_hourly_sum_statistics_amount( "last_reset": None, } seq = [10, 15, 20, 10, 30, 40, 50, 60, 70] - - four, eight, states = await hass.async_add_executor_job( - record_meter_states, hass, period0, "sensor.test1", attributes, seq - ) + with freeze_time(period0) as freezer: + four, eight, states = await hass.async_add_executor_job( + record_meter_states, hass, freezer, period0, "sensor.test1", attributes, seq + ) await async_wait_recording_done(hass) hist = history.get_significant_states( hass, @@ -938,6 +930,8 @@ async def test_compile_hourly_sum_statistics_amount( assert "Compiling initial sum statistics for sensor.test1" in caplog.text assert "Detected new cycle for sensor.test1, value dropped" not in caplog.text + client = await hass_ws_client() + # Adjust the inserted statistics await client.send_json( { @@ -1028,28 +1022,28 @@ def test_compile_hourly_sum_statistics_amount_reset_every_state_change( assert seq[0] != seq[-1] states = {"sensor.test1": []} + with freeze_time(zero) as freezer: + # Insert states for a 1st statistics period + one = zero + for i in range(len(seq)): + one = one + timedelta(seconds=5) + attributes = dict(attributes) + attributes["last_reset"] = dt_util.as_local(one).isoformat() + _states = record_meter_state( + hass, freezer, one, "sensor.test1", attributes, seq[i : i + 1] + ) + states["sensor.test1"].extend(_states["sensor.test1"]) - # Insert states for a 1st statistics period - one = zero - for i in range(len(seq)): - one = one + timedelta(seconds=5) - attributes = dict(attributes) - attributes["last_reset"] = dt_util.as_local(one).isoformat() - _states = record_meter_state( - hass, one, "sensor.test1", attributes, seq[i : i + 1] - ) - states["sensor.test1"].extend(_states["sensor.test1"]) - - # Insert states for a 2nd statistics period - two = zero + timedelta(minutes=5) - for i in range(len(seq)): - two = two + timedelta(seconds=5) - attributes = dict(attributes) - attributes["last_reset"] = dt_util.as_local(two).isoformat() - _states = record_meter_state( - hass, two, "sensor.test1", attributes, seq[i : i + 1] - ) - states["sensor.test1"].extend(_states["sensor.test1"]) + # Insert states for a 2nd statistics period + two = zero + timedelta(minutes=5) + for i in range(len(seq)): + two = two + timedelta(seconds=5) + attributes = dict(attributes) + attributes["last_reset"] = dt_util.as_local(two).isoformat() + _states = record_meter_state( + hass, freezer, two, "sensor.test1", attributes, seq[i : i + 1] + ) + states["sensor.test1"].extend(_states["sensor.test1"]) hist = history.get_significant_states( hass, @@ -1147,17 +1141,18 @@ def test_compile_hourly_sum_statistics_amount_invalid_last_reset( states = {"sensor.test1": []} # Insert states - one = zero - for i in range(len(seq)): - one = one + timedelta(seconds=5) - attributes = dict(attributes) - attributes["last_reset"] = dt_util.as_local(one).isoformat() - if i == 3: - attributes["last_reset"] = "festivus" # not a valid time - _states = record_meter_state( - hass, one, "sensor.test1", attributes, seq[i : i + 1] - ) - states["sensor.test1"].extend(_states["sensor.test1"]) + with freeze_time(zero) as freezer: + one = zero + for i in range(len(seq)): + one = one + timedelta(seconds=5) + attributes = dict(attributes) + attributes["last_reset"] = dt_util.as_local(one).isoformat() + if i == 3: + attributes["last_reset"] = "festivus" # not a valid time + _states = record_meter_state( + hass, freezer, one, "sensor.test1", attributes, seq[i : i + 1] + ) + states["sensor.test1"].extend(_states["sensor.test1"]) hist = history.get_significant_states( hass, @@ -1243,15 +1238,16 @@ def test_compile_hourly_sum_statistics_nan_inf_state( seq = [10, math.nan, 15, 15, 20, math.inf, 20, 10] states = {"sensor.test1": []} - one = zero - for i in range(len(seq)): - one = one + timedelta(seconds=5) - attributes = dict(attributes) - attributes["last_reset"] = dt_util.as_local(one).isoformat() - _states = record_meter_state( - hass, one, "sensor.test1", attributes, seq[i : i + 1] - ) - states["sensor.test1"].extend(_states["sensor.test1"]) + with freeze_time(zero) as freezer: + one = zero + for i in range(len(seq)): + one = one + timedelta(seconds=5) + attributes = dict(attributes) + attributes["last_reset"] = dt_util.as_local(one).isoformat() + _states = record_meter_state( + hass, freezer, one, "sensor.test1", attributes, seq[i : i + 1] + ) + states["sensor.test1"].extend(_states["sensor.test1"]) hist = history.get_significant_states( hass, @@ -1391,10 +1387,13 @@ def test_compile_hourly_sum_statistics_negative_state( states[entity_id].append(state) offending_state = 6 one = zero - for i in range(len(seq)): - one = one + timedelta(seconds=5) - _states = record_meter_state(hass, one, entity_id, attributes, seq[i : i + 1]) - states[entity_id].extend(_states[entity_id]) + with freeze_time(zero) as freezer: + for i in range(len(seq)): + one = one + timedelta(seconds=5) + _states = record_meter_state( + hass, freezer, one, entity_id, attributes, seq[i : i + 1] + ) + states[entity_id].extend(_states[entity_id]) hist = history.get_significant_states( hass, @@ -1486,10 +1485,10 @@ def test_compile_hourly_sum_statistics_total_no_reset( "unit_of_measurement": state_unit, } seq = [10, 15, 20, 10, 30, 40, 50, 60, 70] - - four, eight, states = record_meter_states( - hass, period0, "sensor.test1", attributes, seq - ) + with freeze_time(period0) as freezer: + four, eight, states = record_meter_states( + hass, freezer, period0, "sensor.test1", attributes, seq + ) wait_recording_done(hass) hist = history.get_significant_states( hass, @@ -1598,10 +1597,10 @@ def test_compile_hourly_sum_statistics_total_increasing( "unit_of_measurement": state_unit, } seq = [10, 15, 20, 10, 30, 40, 50, 60, 70] - - four, eight, states = record_meter_states( - hass, period0, "sensor.test1", attributes, seq - ) + with freeze_time(period0) as freezer: + four, eight, states = record_meter_states( + hass, freezer, period0, "sensor.test1", attributes, seq + ) wait_recording_done(hass) hist = history.get_significant_states( hass, @@ -1708,10 +1707,10 @@ def test_compile_hourly_sum_statistics_total_increasing_small_dip( "unit_of_measurement": state_unit, } seq = [10, 15, 20, 19, 30, 40, 39, 60, 70] - - four, eight, states = record_meter_states( - hass, period0, "sensor.test1", attributes, seq - ) + with freeze_time(period0) as freezer: + four, eight, states = record_meter_states( + hass, freezer, period0, "sensor.test1", attributes, seq + ) wait_recording_done(hass) hist = history.get_significant_states( hass, @@ -1816,12 +1815,17 @@ def test_compile_hourly_energy_statistics_unsupported( seq2 = [110, 120, 130, 0, 30, 45, 55, 65, 75] seq3 = [0, 0, 5, 10, 30, 50, 60, 80, 90] - four, eight, states = record_meter_states( - hass, period0, "sensor.test1", sns1_attr, seq1 - ) - _, _, _states = record_meter_states(hass, period0, "sensor.test2", sns2_attr, seq2) - states = {**states, **_states} - _, _, _states = record_meter_states(hass, period0, "sensor.test3", sns3_attr, seq3) + with freeze_time(period0) as freezer: + four, eight, states = record_meter_states( + hass, freezer, period0, "sensor.test1", sns1_attr, seq1 + ) + _, _, _states = record_meter_states( + hass, freezer, period0, "sensor.test2", sns2_attr, seq2 + ) + states = {**states, **_states} + _, _, _states = record_meter_states( + hass, freezer, period0, "sensor.test3", sns3_attr, seq3 + ) states = {**states, **_states} wait_recording_done(hass) @@ -1914,12 +1918,17 @@ def test_compile_hourly_energy_statistics_multiple( seq2 = [110, 120, 130, 0, 30, 45, 55, 65, 75] seq3 = [0, 0, 5, 10, 30, 50, 60, 80, 90] - four, eight, states = record_meter_states( - hass, period0, "sensor.test1", sns1_attr, seq1 - ) - _, _, _states = record_meter_states(hass, period0, "sensor.test2", sns2_attr, seq2) - states = {**states, **_states} - _, _, _states = record_meter_states(hass, period0, "sensor.test3", sns3_attr, seq3) + with freeze_time(period0) as freezer: + four, eight, states = record_meter_states( + hass, freezer, period0, "sensor.test1", sns1_attr, seq1 + ) + _, _, _states = record_meter_states( + hass, freezer, period0, "sensor.test2", sns2_attr, seq2 + ) + states = {**states, **_states} + _, _, _states = record_meter_states( + hass, freezer, period0, "sensor.test3", sns3_attr, seq3 + ) states = {**states, **_states} wait_recording_done(hass) hist = history.get_significant_states( @@ -2114,7 +2123,8 @@ def test_compile_hourly_statistics_unchanged( "state_class": "measurement", "unit_of_measurement": state_unit, } - four, states = record_states(hass, zero, "sensor.test1", attributes) + with freeze_time(zero) as freezer: + four, states = record_states(hass, freezer, zero, "sensor.test1", attributes) hist = history.get_significant_states( hass, zero, four, hass.states.async_entity_ids() ) @@ -2224,7 +2234,8 @@ def test_compile_hourly_statistics_unavailable( four, states = record_states_partially_unavailable( hass, zero, "sensor.test1", attributes ) - _, _states = record_states(hass, zero, "sensor.test2", attributes) + with freeze_time(zero) as freezer: + _, _states = record_states(hass, freezer, zero, "sensor.test2", attributes) states = {**states, **_states} hist = history.get_significant_states( hass, zero, four, hass.states.async_entity_ids() @@ -2440,16 +2451,17 @@ def test_compile_hourly_statistics_changing_units_1( "state_class": "measurement", "unit_of_measurement": state_unit, } - four, states = record_states(hass, zero, "sensor.test1", attributes) - attributes["unit_of_measurement"] = state_unit2 - four, _states = record_states( - hass, zero + timedelta(minutes=5), "sensor.test1", attributes - ) - states["sensor.test1"] += _states["sensor.test1"] - four, _states = record_states( - hass, zero + timedelta(minutes=10), "sensor.test1", attributes - ) - states["sensor.test1"] += _states["sensor.test1"] + with freeze_time(zero) as freezer: + four, states = record_states(hass, freezer, zero, "sensor.test1", attributes) + attributes["unit_of_measurement"] = state_unit2 + four, _states = record_states( + hass, freezer, zero + timedelta(minutes=5), "sensor.test1", attributes + ) + states["sensor.test1"] += _states["sensor.test1"] + four, _states = record_states( + hass, freezer, zero + timedelta(minutes=10), "sensor.test1", attributes + ) + states["sensor.test1"] += _states["sensor.test1"] hist = history.get_significant_states( hass, zero, four, hass.states.async_entity_ids() ) @@ -2565,12 +2577,13 @@ def test_compile_hourly_statistics_changing_units_2( "state_class": "measurement", "unit_of_measurement": state_unit, } - four, states = record_states(hass, zero, "sensor.test1", attributes) - attributes["unit_of_measurement"] = "cats" - four, _states = record_states( - hass, zero + timedelta(minutes=5), "sensor.test1", attributes - ) - states["sensor.test1"] += _states["sensor.test1"] + with freeze_time(zero) as freezer: + four, states = record_states(hass, freezer, zero, "sensor.test1", attributes) + attributes["unit_of_measurement"] = "cats" + four, _states = record_states( + hass, freezer, zero + timedelta(minutes=5), "sensor.test1", attributes + ) + states["sensor.test1"] += _states["sensor.test1"] hist = history.get_significant_states( hass, zero, four, hass.states.async_entity_ids() ) @@ -2640,16 +2653,17 @@ def test_compile_hourly_statistics_changing_units_3( "state_class": "measurement", "unit_of_measurement": state_unit, } - four, states = record_states(hass, zero, "sensor.test1", attributes) - four, _states = record_states( - hass, zero + timedelta(minutes=5), "sensor.test1", attributes - ) - states["sensor.test1"] += _states["sensor.test1"] - attributes["unit_of_measurement"] = "cats" - four, _states = record_states( - hass, zero + timedelta(minutes=10), "sensor.test1", attributes - ) - states["sensor.test1"] += _states["sensor.test1"] + with freeze_time(zero) as freezer: + four, states = record_states(hass, freezer, zero, "sensor.test1", attributes) + four, _states = record_states( + hass, freezer, zero + timedelta(minutes=5), "sensor.test1", attributes + ) + states["sensor.test1"] += _states["sensor.test1"] + attributes["unit_of_measurement"] = "cats" + four, _states = record_states( + hass, freezer, zero + timedelta(minutes=10), "sensor.test1", attributes + ) + states["sensor.test1"] += _states["sensor.test1"] hist = history.get_significant_states( hass, zero, four, hass.states.async_entity_ids() ) @@ -2757,10 +2771,16 @@ def test_compile_hourly_statistics_convert_units_1( "state_class": "measurement", "unit_of_measurement": state_unit_1, } - four, states = record_states(hass, zero, "sensor.test1", attributes) - four, _states = record_states( - hass, zero + timedelta(minutes=5), "sensor.test1", attributes, seq=[0, 1, None] - ) + with freeze_time(zero) as freezer: + four, states = record_states(hass, freezer, zero, "sensor.test1", attributes) + four, _states = record_states( + hass, + freezer, + zero + timedelta(minutes=5), + "sensor.test1", + attributes, + seq=[0, 1, None], + ) states["sensor.test1"] += _states["sensor.test1"] do_adhoc_statistics(hass, start=zero) @@ -2796,9 +2816,10 @@ def test_compile_hourly_statistics_convert_units_1( } attributes["unit_of_measurement"] = state_unit_2 - four, _states = record_states( - hass, zero + timedelta(minutes=10), "sensor.test1", attributes - ) + with freeze_time(four) as freezer: + four, _states = record_states( + hass, freezer, zero + timedelta(minutes=10), "sensor.test1", attributes + ) states["sensor.test1"] += _states["sensor.test1"] hist = history.get_significant_states( hass, zero, four, hass.states.async_entity_ids() @@ -2894,15 +2915,16 @@ def test_compile_hourly_statistics_equivalent_units_1( "state_class": "measurement", "unit_of_measurement": state_unit, } - four, states = record_states(hass, zero, "sensor.test1", attributes) - attributes["unit_of_measurement"] = state_unit2 - four, _states = record_states( - hass, zero + timedelta(minutes=5), "sensor.test1", attributes - ) - states["sensor.test1"] += _states["sensor.test1"] - four, _states = record_states( - hass, zero + timedelta(minutes=10), "sensor.test1", attributes - ) + with freeze_time(zero) as freezer: + four, states = record_states(hass, freezer, zero, "sensor.test1", attributes) + attributes["unit_of_measurement"] = state_unit2 + four, _states = record_states( + hass, freezer, zero + timedelta(minutes=5), "sensor.test1", attributes + ) + states["sensor.test1"] += _states["sensor.test1"] + four, _states = record_states( + hass, freezer, zero + timedelta(minutes=10), "sensor.test1", attributes + ) states["sensor.test1"] += _states["sensor.test1"] hist = history.get_significant_states( hass, zero, four, hass.states.async_entity_ids() @@ -3014,11 +3036,12 @@ def test_compile_hourly_statistics_equivalent_units_2( "state_class": "measurement", "unit_of_measurement": state_unit, } - four, states = record_states(hass, zero, "sensor.test1", attributes) - attributes["unit_of_measurement"] = state_unit2 - four, _states = record_states( - hass, zero + timedelta(minutes=5), "sensor.test1", attributes - ) + with freeze_time(zero) as freezer: + four, states = record_states(hass, freezer, zero, "sensor.test1", attributes) + attributes["unit_of_measurement"] = state_unit2 + four, _states = record_states( + hass, freezer, zero + timedelta(minutes=5), "sensor.test1", attributes + ) states["sensor.test1"] += _states["sensor.test1"] hist = history.get_significant_states( hass, zero, four, hass.states.async_entity_ids() @@ -3104,7 +3127,8 @@ def test_compile_hourly_statistics_changing_device_class_1( "state_class": "measurement", "unit_of_measurement": state_unit, } - four, states = record_states(hass, zero, "sensor.test1", attributes) + with freeze_time(zero) as freezer: + four, states = record_states(hass, freezer, zero, "sensor.test1", attributes) do_adhoc_statistics(hass, start=zero) wait_recording_done(hass) @@ -3140,13 +3164,14 @@ def test_compile_hourly_statistics_changing_device_class_1( # Update device class and record additional states in the original UoM attributes["device_class"] = device_class - four, _states = record_states( - hass, zero + timedelta(minutes=5), "sensor.test1", attributes - ) - states["sensor.test1"] += _states["sensor.test1"] - four, _states = record_states( - hass, zero + timedelta(minutes=10), "sensor.test1", attributes - ) + with freeze_time(zero) as freezer: + four, _states = record_states( + hass, freezer, zero + timedelta(minutes=5), "sensor.test1", attributes + ) + states["sensor.test1"] += _states["sensor.test1"] + four, _states = record_states( + hass, freezer, zero + timedelta(minutes=10), "sensor.test1", attributes + ) states["sensor.test1"] += _states["sensor.test1"] hist = history.get_significant_states( hass, zero, four, hass.states.async_entity_ids() @@ -3197,13 +3222,14 @@ def test_compile_hourly_statistics_changing_device_class_1( # Update device class and record additional states in a different UoM attributes["unit_of_measurement"] = statistic_unit - four, _states = record_states( - hass, zero + timedelta(minutes=15), "sensor.test1", attributes - ) - states["sensor.test1"] += _states["sensor.test1"] - four, _states = record_states( - hass, zero + timedelta(minutes=20), "sensor.test1", attributes - ) + with freeze_time(zero) as freezer: + four, _states = record_states( + hass, freezer, zero + timedelta(minutes=15), "sensor.test1", attributes + ) + states["sensor.test1"] += _states["sensor.test1"] + four, _states = record_states( + hass, freezer, zero + timedelta(minutes=20), "sensor.test1", attributes + ) states["sensor.test1"] += _states["sensor.test1"] hist = history.get_significant_states( hass, zero, four, hass.states.async_entity_ids() @@ -3308,7 +3334,8 @@ def test_compile_hourly_statistics_changing_device_class_2( "state_class": "measurement", "unit_of_measurement": state_unit, } - four, states = record_states(hass, zero, "sensor.test1", attributes) + with freeze_time(zero) as freezer: + four, states = record_states(hass, freezer, zero, "sensor.test1", attributes) do_adhoc_statistics(hass, start=zero) wait_recording_done(hass) @@ -3344,13 +3371,14 @@ def test_compile_hourly_statistics_changing_device_class_2( # Remove device class and record additional states attributes.pop("device_class") - four, _states = record_states( - hass, zero + timedelta(minutes=5), "sensor.test1", attributes - ) - states["sensor.test1"] += _states["sensor.test1"] - four, _states = record_states( - hass, zero + timedelta(minutes=10), "sensor.test1", attributes - ) + with freeze_time(zero) as freezer: + four, _states = record_states( + hass, freezer, zero + timedelta(minutes=5), "sensor.test1", attributes + ) + states["sensor.test1"] += _states["sensor.test1"] + four, _states = record_states( + hass, freezer, zero + timedelta(minutes=10), "sensor.test1", attributes + ) states["sensor.test1"] += _states["sensor.test1"] hist = history.get_significant_states( hass, zero, four, hass.states.async_entity_ids() @@ -3445,7 +3473,10 @@ def test_compile_hourly_statistics_changing_state_class( "state_class": "total_increasing", "unit_of_measurement": state_unit, } - four, states = record_states(hass, period0, "sensor.test1", attributes_1) + with freeze_time(period0) as freezer: + four, states = record_states( + hass, freezer, period0, "sensor.test1", attributes_1 + ) do_adhoc_statistics(hass, start=period0) wait_recording_done(hass) statistic_ids = list_statistic_ids(hass) @@ -3477,7 +3508,10 @@ def test_compile_hourly_statistics_changing_state_class( } # Add more states, with changed state class - four, _states = record_states(hass, period1, "sensor.test1", attributes_2) + with freeze_time(period1) as freezer: + four, _states = record_states( + hass, freezer, period1, "sensor.test1", attributes_2 + ) states["sensor.test1"] += _states["sensor.test1"] hist = history.get_significant_states( hass, period0, four, hass.states.async_entity_ids() @@ -3542,6 +3576,7 @@ def test_compile_hourly_statistics_changing_state_class( assert "Error while processing event StatisticsTask" not in caplog.text +@pytest.mark.timeout(25) def test_compile_statistics_hourly_daily_monthly_summary( hass_recorder: Callable[..., HomeAssistant], caplog: pytest.LogCaptureFixture ) -> None: @@ -3551,9 +3586,7 @@ def test_compile_statistics_hourly_daily_monthly_summary( zero = zero.replace( year=2021, month=9, day=1, hour=5, minute=0, second=0, microsecond=0 ) - with patch( - "homeassistant.components.recorder.db_schema.dt_util.utcnow", return_value=zero - ): + with freeze_time(zero): hass = hass_recorder() # Remove this after dropping the use of the hass_recorder fixture hass.config.set_time_zone("America/Regina") @@ -3618,55 +3651,69 @@ def test_compile_statistics_hourly_daily_monthly_summary( "sensor.test4": None, } start = zero - for i in range(24): - seq = [-10, 15, 30] - # test1 has same value in every period - four, _states = record_states(hass, start, "sensor.test1", attributes, seq) - states["sensor.test1"] += _states["sensor.test1"] - last_state = last_states["sensor.test1"] - expected_minima["sensor.test1"].append(_min(seq, last_state)) - expected_maxima["sensor.test1"].append(_max(seq, last_state)) - expected_averages["sensor.test1"].append(_weighted_average(seq, i, last_state)) - last_states["sensor.test1"] = seq[-1] - # test2 values change: min/max at the last state - seq = [-10 * (i + 1), 15 * (i + 1), 30 * (i + 1)] - four, _states = record_states(hass, start, "sensor.test2", attributes, seq) - states["sensor.test2"] += _states["sensor.test2"] - last_state = last_states["sensor.test2"] - expected_minima["sensor.test2"].append(_min(seq, last_state)) - expected_maxima["sensor.test2"].append(_max(seq, last_state)) - expected_averages["sensor.test2"].append(_weighted_average(seq, i, last_state)) - last_states["sensor.test2"] = seq[-1] - # test3 values change: min/max at the first state - seq = [-10 * (23 - i + 1), 15 * (23 - i + 1), 30 * (23 - i + 1)] - four, _states = record_states(hass, start, "sensor.test3", attributes, seq) - states["sensor.test3"] += _states["sensor.test3"] - last_state = last_states["sensor.test3"] - expected_minima["sensor.test3"].append(_min(seq, last_state)) - expected_maxima["sensor.test3"].append(_max(seq, last_state)) - expected_averages["sensor.test3"].append(_weighted_average(seq, i, last_state)) - last_states["sensor.test3"] = seq[-1] - # test4 values grow - seq = [i, i + 0.5, i + 0.75] - start_meter = start - for j in range(len(seq)): - _states = record_meter_state( - hass, - start_meter, - "sensor.test4", - sum_attributes, - seq[j : j + 1], + with freeze_time(start) as freezer: + for i in range(24): + seq = [-10, 15, 30] + # test1 has same value in every period + four, _states = record_states( + hass, freezer, start, "sensor.test1", attributes, seq ) - start_meter += timedelta(minutes=1) - states["sensor.test4"] += _states["sensor.test4"] - last_state = last_states["sensor.test4"] - expected_states["sensor.test4"].append(seq[-1]) - expected_sums["sensor.test4"].append( - _sum(seq, last_state, expected_sums["sensor.test4"]) - ) - last_states["sensor.test4"] = seq[-1] + states["sensor.test1"] += _states["sensor.test1"] + last_state = last_states["sensor.test1"] + expected_minima["sensor.test1"].append(_min(seq, last_state)) + expected_maxima["sensor.test1"].append(_max(seq, last_state)) + expected_averages["sensor.test1"].append( + _weighted_average(seq, i, last_state) + ) + last_states["sensor.test1"] = seq[-1] + # test2 values change: min/max at the last state + seq = [-10 * (i + 1), 15 * (i + 1), 30 * (i + 1)] + four, _states = record_states( + hass, freezer, start, "sensor.test2", attributes, seq + ) + states["sensor.test2"] += _states["sensor.test2"] + last_state = last_states["sensor.test2"] + expected_minima["sensor.test2"].append(_min(seq, last_state)) + expected_maxima["sensor.test2"].append(_max(seq, last_state)) + expected_averages["sensor.test2"].append( + _weighted_average(seq, i, last_state) + ) + last_states["sensor.test2"] = seq[-1] + # test3 values change: min/max at the first state + seq = [-10 * (23 - i + 1), 15 * (23 - i + 1), 30 * (23 - i + 1)] + four, _states = record_states( + hass, freezer, start, "sensor.test3", attributes, seq + ) + states["sensor.test3"] += _states["sensor.test3"] + last_state = last_states["sensor.test3"] + expected_minima["sensor.test3"].append(_min(seq, last_state)) + expected_maxima["sensor.test3"].append(_max(seq, last_state)) + expected_averages["sensor.test3"].append( + _weighted_average(seq, i, last_state) + ) + last_states["sensor.test3"] = seq[-1] + # test4 values grow + seq = [i, i + 0.5, i + 0.75] + start_meter = start + for j in range(len(seq)): + _states = record_meter_state( + hass, + freezer, + start_meter, + "sensor.test4", + sum_attributes, + seq[j : j + 1], + ) + start_meter += timedelta(minutes=1) + states["sensor.test4"] += _states["sensor.test4"] + last_state = last_states["sensor.test4"] + expected_states["sensor.test4"].append(seq[-1]) + expected_sums["sensor.test4"].append( + _sum(seq, last_state, expected_sums["sensor.test4"]) + ) + last_states["sensor.test4"] = seq[-1] - start += timedelta(minutes=5) + start += timedelta(minutes=5) hist = history.get_significant_states( hass, zero - timedelta.resolution, @@ -3961,7 +4008,14 @@ def test_compile_statistics_hourly_daily_monthly_summary( assert "Error while processing event StatisticsTask" not in caplog.text -def record_states(hass, zero, entity_id, attributes, seq=None): +def record_states( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + zero: datetime, + entity_id: str, + attributes, + seq=None, +): """Record some test states. We inject a bunch of state updates for measurement sensors. @@ -3982,26 +4036,14 @@ def record_states(hass, zero, entity_id, attributes, seq=None): four = three + timedelta(seconds=10 * 5) states = {entity_id: []} - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=one - ): - states[entity_id].append( - set_state(entity_id, str(seq[0]), attributes=attributes) - ) + freezer.move_to(one) + states[entity_id].append(set_state(entity_id, str(seq[0]), attributes=attributes)) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=two - ): - states[entity_id].append( - set_state(entity_id, str(seq[1]), attributes=attributes) - ) + freezer.move_to(two) + states[entity_id].append(set_state(entity_id, str(seq[1]), attributes=attributes)) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=three - ): - states[entity_id].append( - set_state(entity_id, str(seq[2]), attributes=attributes) - ) + freezer.move_to(three) + states[entity_id].append(set_state(entity_id, str(seq[2]), attributes=attributes)) return four, states @@ -4989,7 +5031,14 @@ async def test_validate_statistics_other_domain( await assert_validation_result(client, {}) -def record_meter_states(hass, zero, entity_id, _attributes, seq): +def record_meter_states( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + zero: datetime, + entity_id: str, + _attributes, + seq, +): """Record some test states. We inject a bunch of state updates for meter sensors. @@ -5014,59 +5063,49 @@ def record_meter_states(hass, zero, entity_id, _attributes, seq): attributes["last_reset"] = zero.isoformat() states = {entity_id: []} - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=zero - ): - states[entity_id].append(set_state(entity_id, seq[0], attributes=attributes)) + freezer.move_to(zero) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=one - ): - states[entity_id].append(set_state(entity_id, seq[1], attributes=attributes)) + states[entity_id].append(set_state(entity_id, seq[0], attributes=attributes)) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=two - ): - states[entity_id].append(set_state(entity_id, seq[2], attributes=attributes)) + freezer.move_to(one) + states[entity_id].append(set_state(entity_id, seq[1], attributes=attributes)) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=three - ): - states[entity_id].append(set_state(entity_id, seq[3], attributes=attributes)) + freezer.move_to(two) + states[entity_id].append(set_state(entity_id, seq[2], attributes=attributes)) + + freezer.move_to(three) + states[entity_id].append(set_state(entity_id, seq[3], attributes=attributes)) attributes = dict(_attributes) if "last_reset" in _attributes: attributes["last_reset"] = four.isoformat() - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=four - ): - states[entity_id].append(set_state(entity_id, seq[4], attributes=attributes)) + freezer.move_to(four) + states[entity_id].append(set_state(entity_id, seq[4], attributes=attributes)) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=five - ): - states[entity_id].append(set_state(entity_id, seq[5], attributes=attributes)) + freezer.move_to(five) + states[entity_id].append(set_state(entity_id, seq[5], attributes=attributes)) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=six - ): - states[entity_id].append(set_state(entity_id, seq[6], attributes=attributes)) + freezer.move_to(six) + states[entity_id].append(set_state(entity_id, seq[6], attributes=attributes)) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=seven - ): - states[entity_id].append(set_state(entity_id, seq[7], attributes=attributes)) + freezer.move_to(seven) + states[entity_id].append(set_state(entity_id, seq[7], attributes=attributes)) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=eight - ): - states[entity_id].append(set_state(entity_id, seq[8], attributes=attributes)) + freezer.move_to(eight) + states[entity_id].append(set_state(entity_id, seq[8], attributes=attributes)) return four, eight, states -def record_meter_state(hass, zero, entity_id, attributes, seq): +def record_meter_state( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + zero: datetime, + entity_id: str, + attributes, + seq, +): """Record test state. We inject a state update for meter sensor. @@ -5079,10 +5118,8 @@ def record_meter_state(hass, zero, entity_id, attributes, seq): return hass.states.get(entity_id) states = {entity_id: []} - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=zero - ): - states[entity_id].append(set_state(entity_id, seq[0], attributes=attributes)) + freezer.move_to(zero) + states[entity_id].append(set_state(entity_id, seq[0], attributes=attributes)) return states @@ -5105,19 +5142,13 @@ def record_states_partially_unavailable(hass, zero, entity_id, attributes): four = three + timedelta(seconds=15 * 5) states = {entity_id: []} - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=one - ): + with freeze_time(one) as freezer: states[entity_id].append(set_state(entity_id, "10", attributes=attributes)) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=two - ): + freezer.move_to(two) states[entity_id].append(set_state(entity_id, "25", attributes=attributes)) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=three - ): + freezer.move_to(three) states[entity_id].append( set_state(entity_id, STATE_UNAVAILABLE, attributes=attributes) ) From 33387bf418640f1c32f07694decbe78aa9c2f87f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Modzelewski?= Date: Thu, 25 May 2023 03:07:49 +0200 Subject: [PATCH 551/822] Readable paired device names for Apple TV airplay and companion protocols (#93449) Co-authored-by: J. Nick Koston --- homeassistant/components/apple_tv/config_flow.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/apple_tv/config_flow.py b/homeassistant/components/apple_tv/config_flow.py index 9b80d992cdd9..8a2130faca07 100644 --- a/homeassistant/components/apple_tv/config_flow.py +++ b/homeassistant/components/apple_tv/config_flow.py @@ -407,8 +407,9 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # Protocol specific arguments pair_args = {} - if self.protocol == Protocol.DMAP: + if self.protocol in {Protocol.AirPlay, Protocol.Companion, Protocol.DMAP}: pair_args["name"] = "Home Assistant" + if self.protocol == Protocol.DMAP: pair_args["zeroconf"] = await zeroconf.async_get_instance(self.hass) # Initiate the pairing process From 2bb46c8a9e4b847fc5b1af414b0b9430e6062666 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Thu, 25 May 2023 03:50:13 +0200 Subject: [PATCH 552/822] Fix for missing/corrupt names of Matter devices (#92073) * Fix missing names on some devices/bridges * guard for None devicetype (just in case) * Update homeassistant/components/matter/adapter.py Co-authored-by: Jan Bouwhuis * Update homeassistant/components/matter/adapter.py Co-authored-by: Jan Bouwhuis * Update homeassistant/components/matter/adapter.py Co-authored-by: Jan Bouwhuis * Update homeassistant/components/matter/adapter.py Co-authored-by: Jan Bouwhuis * black and isort * Code quality * Add tests * Add coverage test, add None for empty string --------- Co-authored-by: Jan Bouwhuis Co-authored-by: jbouwh --- homeassistant/components/matter/adapter.py | 38 +- .../fixtures/nodes/onoff-light-alt-name.json | 411 ++++++++++++++++++ .../fixtures/nodes/onoff-light-no-name.json | 411 ++++++++++++++++++ tests/components/matter/test_adapter.py | 29 +- 4 files changed, 883 insertions(+), 6 deletions(-) create mode 100644 tests/components/matter/fixtures/nodes/onoff-light-alt-name.json create mode 100644 tests/components/matter/fixtures/nodes/onoff-light-no-name.json diff --git a/homeassistant/components/matter/adapter.py b/homeassistant/components/matter/adapter.py index fbc027091b4d..7d73ceafc7a8 100644 --- a/homeassistant/components/matter/adapter.py +++ b/homeassistant/components/matter/adapter.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import TYPE_CHECKING, cast +from matter_server.client.models.device_types import BridgedDevice from matter_server.common.models import EventType, ServerInfoMessage from homeassistant.config_entries import ConfigEntry @@ -20,6 +21,14 @@ if TYPE_CHECKING: from matter_server.client.models.node import MatterEndpoint, MatterNode +def get_clean_name(name: str | None) -> str | None: + """Strip spaces and null char from the name.""" + if name is None: + return name + name = name.replace("\x00", "") + return name.strip() or None + + class MatterAdapter: """Connect Matter into Home Assistant.""" @@ -70,11 +79,27 @@ class MatterAdapter: server_info = cast(ServerInfoMessage, self.matter_client.server_info) basic_info = endpoint.device_info - name = basic_info.nodeLabel or basic_info.productLabel or basic_info.productName + # use (first) DeviceType of the endpoint as fallback product name + device_type = next( + ( + x + for x in endpoint.device_types + if x.device_type != BridgedDevice.device_type + ), + None, + ) + name = ( + get_clean_name(basic_info.nodeLabel) + or get_clean_name(basic_info.productLabel) + or get_clean_name(basic_info.productName) + or device_type.__class__.__name__ + if device_type + else None + ) # handle bridged devices bridge_device_id = None - if endpoint.is_bridged_device: + if endpoint.is_bridged_device and endpoint.node.endpoints[0] != endpoint: bridge_device_id = get_device_id( server_info, endpoint.node.endpoints[0], @@ -91,14 +116,19 @@ class MatterAdapter: # prefix identifier with 'serial_' to be able to filter it identifiers.add((DOMAIN, f"{ID_TYPE_SERIAL}_{basic_info.serialNumber}")) + model = ( + get_clean_name(basic_info.productName) or device_type.__class__.__name__ + if device_type + else None + ) dr.async_get(self.hass).async_get_or_create( name=name, config_entry_id=self.config_entry.entry_id, identifiers=identifiers, hw_version=basic_info.hardwareVersionString, sw_version=basic_info.softwareVersionString, - manufacturer=basic_info.vendorName, - model=basic_info.productName, + manufacturer=basic_info.vendorName or endpoint.node.device_info.vendorName, + model=model, via_device=(DOMAIN, bridge_device_id) if bridge_device_id else None, ) diff --git a/tests/components/matter/fixtures/nodes/onoff-light-alt-name.json b/tests/components/matter/fixtures/nodes/onoff-light-alt-name.json new file mode 100644 index 000000000000..8b1f1004e592 --- /dev/null +++ b/tests/components/matter/fixtures/nodes/onoff-light-alt-name.json @@ -0,0 +1,411 @@ +{ + "node_id": 1, + "date_commissioned": "2022-11-29T21:23:48.485051", + "last_interview": "2022-11-29T21:23:48.485057", + "interview_version": 2, + "attributes": { + "0/4/0": 128, + "0/4/65532": 1, + "0/4/65533": 4, + "0/4/65528": [0, 1, 2, 3], + "0/4/65529": [0, 1, 2, 3, 4, 5], + "0/4/65531": [0, 65528, 65529, 65531, 65532, 65533], + "0/29/0": [ + { + "type": 22, + "revision": 1 + } + ], + "0/29/1": [ + 4, 29, 31, 40, 42, 43, 44, 48, 49, 50, 51, 52, 53, 54, 55, 59, 60, 62, 63, + 64, 65 + ], + "0/29/2": [41], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65533": 1, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/31/0": [ + { + "privilege": 5, + "authMode": 2, + "subjects": [112233], + "targets": null, + "fabricIndex": 1 + } + ], + "0/31/1": [], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 3, + "0/31/65532": 0, + "0/31/65533": 1, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/40/0": 1, + "0/40/1": "Nabu Casa", + "0/40/2": 65521, + "0/40/3": "Mock Light", + "0/40/4": 32768, + "0/40/5": "Mock OnOff Light \u0000", + "0/40/6": "XX", + "0/40/7": 0, + "0/40/8": "v1.0", + "0/40/9": 1, + "0/40/10": "v1.0", + "0/40/11": "20200101", + "0/40/12": "", + "0/40/13": "", + "0/40/14": "", + "0/40/15": "12345678", + "0/40/16": false, + "0/40/17": true, + "0/40/18": "mock-onoff-light", + "0/40/19": { + "caseSessionsPerFabric": 3, + "subscriptionsPerFabric": 3 + }, + "0/40/65532": 0, + "0/40/65533": 1, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, + 65528, 65529, 65531, 65532, 65533 + ], + "0/42/0": [], + "0/42/1": true, + "0/42/2": 0, + "0/42/3": 0, + "0/42/65532": 0, + "0/42/65533": 1, + "0/42/65528": [], + "0/42/65529": [0], + "0/42/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/43/0": "en-US", + "0/43/1": [ + "en-US", + "de-DE", + "fr-FR", + "en-GB", + "es-ES", + "zh-CN", + "it-IT", + "ja-JP" + ], + "0/43/65532": 0, + "0/43/65533": 1, + "0/43/65528": [], + "0/43/65529": [], + "0/43/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "0/44/0": 0, + "0/44/1": 0, + "0/44/2": [0, 1, 2, 3, 4, 5, 6, 8, 9, 10, 11, 7], + "0/44/65532": 0, + "0/44/65533": 1, + "0/44/65528": [], + "0/44/65529": [], + "0/44/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/48/0": 0, + "0/48/1": { + "failSafeExpiryLengthSeconds": 60, + "maxCumulativeFailsafeSeconds": 900 + }, + "0/48/2": 0, + "0/48/3": 0, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 1, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/49/0": 1, + "0/49/1": [ + { + "networkID": "", + "connected": true + } + ], + "0/49/2": 10, + "0/49/3": 30, + "0/49/4": true, + "0/49/5": 0, + "0/49/6": "", + "0/49/7": null, + "0/49/65532": 1, + "0/49/65533": 1, + "0/49/65528": [1, 5, 7], + "0/49/65529": [0, 2, 4, 6, 8], + "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], + "0/50/65532": 0, + "0/50/65533": 1, + "0/50/65528": [1], + "0/50/65529": [0], + "0/50/65531": [65528, 65529, 65531, 65532, 65533], + "0/51/0": [ + { + "name": "WIFI_STA_DEF", + "isOperational": true, + "offPremiseServicesReachableIPv4": null, + "offPremiseServicesReachableIPv6": null, + "hardwareAddress": "", + "IPv4Addresses": [""], + "IPv6Addresses": [], + "type": 1 + } + ], + "0/51/1": 6, + "0/51/2": 31279, + "0/51/3": 8, + "0/51/4": 1, + "0/51/5": [], + "0/51/6": [], + "0/51/7": [], + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 1, + "0/51/65528": [], + "0/51/65529": [0], + "0/51/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533 + ], + "0/52/0": [], + "0/52/1": 166480, + "0/52/2": 86512, + "0/52/3": 157052, + "0/52/65532": 0, + "0/52/65533": 1, + "0/52/65528": [], + "0/52/65529": [], + "0/52/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/53/65532": 15, + "0/53/65533": 1, + "0/53/65528": [], + "0/53/65529": [0], + "0/53/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, + 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, + 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, + 57, 58, 59, 60, 61, 62, 65528, 65529, 65531, 65532, 65533 + ], + "0/54/0": "", + "0/54/1": 4, + "0/54/2": 3, + "0/54/3": 6, + "0/54/4": -61, + "0/54/5": null, + "0/54/6": null, + "0/54/7": null, + "0/54/8": null, + "0/54/9": null, + "0/54/10": null, + "0/54/11": null, + "0/54/12": null, + "0/54/65532": 3, + "0/54/65533": 1, + "0/54/65528": [], + "0/54/65529": [0], + "0/54/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 65528, 65529, 65531, 65532, + 65533 + ], + "0/55/0": null, + "0/55/1": null, + "0/55/2": 0, + "0/55/3": 0, + "0/55/4": 0, + "0/55/5": 0, + "0/55/6": 0, + "0/55/7": null, + "0/55/8": 0, + "0/55/65532": 3, + "0/55/65533": 1, + "0/55/65528": [], + "0/55/65529": [0], + "0/55/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533 + ], + "0/59/65532": 0, + "0/59/65533": 1, + "0/59/65528": [], + "0/59/65529": [], + "0/59/65531": [65528, 65529, 65531, 65532, 65533], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 0, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 1, 2], + "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/62/0": [ + { + "noc": "", + "icac": "", + "fabricIndex": 1 + } + ], + "0/62/1": [ + { + "rootPublicKey": "", + "vendorId": 65521, + "fabricId": 1, + "nodeId": 1, + "label": "", + "fabricIndex": 1 + } + ], + "0/62/2": 5, + "0/62/3": 1, + "0/62/4": [""], + "0/62/5": 1, + "0/62/65532": 0, + "0/62/65533": 1, + "0/62/65528": [1, 3, 5, 8], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 3, + "0/63/3": 3, + "0/63/65532": 0, + "0/63/65533": 1, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/64/0": [ + { + "label": "room", + "value": "bedroom 2" + }, + { + "label": "orientation", + "value": "North" + }, + { + "label": "floor", + "value": "2" + }, + { + "label": "direction", + "value": "up" + } + ], + "0/64/65532": 0, + "0/64/65533": 1, + "0/64/65528": [], + "0/64/65529": [], + "0/64/65531": [0, 65528, 65529, 65531, 65532, 65533], + "0/65/0": [], + "0/65/65532": 0, + "0/65/65533": 1, + "0/65/65528": [], + "0/65/65529": [], + "0/65/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/3/0": 0, + "1/3/1": 0, + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/65528": [], + "1/3/65529": [0, 64], + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/4/0": 128, + "1/4/65532": 1, + "1/4/65533": 4, + "1/4/65528": [0, 1, 2, 3], + "1/4/65529": [0, 1, 2, 3, 4, 5], + "1/4/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/6/0": true, + "1/6/16384": true, + "1/6/16385": 0, + "1/6/16386": 0, + "1/6/16387": null, + "1/6/65532": 1, + "1/6/65533": 4, + "1/6/65528": [], + "1/6/65529": [0, 1, 2, 64, 65, 66], + "1/6/65531": [ + 0, 16384, 16385, 16386, 16387, 65528, 65529, 65531, 65532, 65533 + ], + "1/8/0": 52, + "1/8/1": 0, + "1/8/2": 1, + "1/8/3": 254, + "1/8/4": 0, + "1/8/5": 0, + "1/8/6": 0, + "1/8/15": 0, + "1/8/16": 0, + "1/8/17": null, + "1/8/18": 0, + "1/8/19": 0, + "1/8/20": 50, + "1/8/16384": null, + "1/8/65532": 3, + "1/8/65533": 5, + "1/8/65528": [], + "1/8/65529": [0, 1, 2, 3, 4, 5, 6, 7], + "1/8/65531": [ + 0, 1, 2, 3, 4, 5, 6, 15, 16, 17, 18, 19, 20, 16384, 65528, 65529, 65531, + 65532, 65533 + ], + "1/29/0": [ + { + "type": 257, + "revision": 1 + } + ], + "1/29/1": [3, 4, 6, 8, 29, 768, 1030], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65533": 1, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/768/0": 0, + "1/768/1": 0, + "1/768/2": 0, + "1/768/3": 24939, + "1/768/4": 24701, + "1/768/7": 0, + "1/768/8": 2, + "1/768/15": 0, + "1/768/16": 0, + "1/768/16384": 0, + "1/768/16385": 2, + "1/768/16386": 0, + "1/768/16387": 0, + "1/768/16388": 25, + "1/768/16389": 8960, + "1/768/16390": 0, + "1/768/16394": 31, + "1/768/16395": 0, + "1/768/16396": 65279, + "1/768/16397": 0, + "1/768/16400": 0, + "1/768/65532": 31, + "1/768/65533": 5, + "1/768/65528": [], + "1/768/65529": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 64, 65, 66, 67, 68, 71, 75, 76 + ], + "1/768/65531": [ + 0, 1, 2, 3, 4, 7, 8, 15, 16, 16384, 16385, 16386, 16387, 16388, 16389, + 16390, 16394, 16395, 16396, 16397, 16400, 65528, 65529, 65531, 65532, + 65533 + ], + "1/1030/0": 0, + "1/1030/1": 0, + "1/1030/2": 1, + "1/1030/65532": 0, + "1/1030/65533": 3, + "1/1030/65528": [], + "1/1030/65529": [], + "1/1030/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533] + }, + "available": true +} diff --git a/tests/components/matter/fixtures/nodes/onoff-light-no-name.json b/tests/components/matter/fixtures/nodes/onoff-light-no-name.json new file mode 100644 index 000000000000..3fa4ca49ccdf --- /dev/null +++ b/tests/components/matter/fixtures/nodes/onoff-light-no-name.json @@ -0,0 +1,411 @@ +{ + "node_id": 1, + "date_commissioned": "2022-11-29T21:23:48.485051", + "last_interview": "2022-11-29T21:23:48.485057", + "interview_version": 2, + "attributes": { + "0/4/0": 128, + "0/4/65532": 1, + "0/4/65533": 4, + "0/4/65528": [0, 1, 2, 3], + "0/4/65529": [0, 1, 2, 3, 4, 5], + "0/4/65531": [0, 65528, 65529, 65531, 65532, 65533], + "0/29/0": [ + { + "type": 22, + "revision": 1 + } + ], + "0/29/1": [ + 4, 29, 31, 40, 42, 43, 44, 48, 49, 50, 51, 52, 53, 54, 55, 59, 60, 62, 63, + 64, 65 + ], + "0/29/2": [41], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65533": 1, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/31/0": [ + { + "privilege": 5, + "authMode": 2, + "subjects": [112233], + "targets": null, + "fabricIndex": 1 + } + ], + "0/31/1": [], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 3, + "0/31/65532": 0, + "0/31/65533": 1, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/40/0": 1, + "0/40/1": "Nabu Casa", + "0/40/2": 65521, + "0/40/3": "Mock Light", + "0/40/4": 32768, + "0/40/5": null, + "0/40/6": "XX", + "0/40/7": 0, + "0/40/8": "v1.0", + "0/40/9": 1, + "0/40/10": "v1.0", + "0/40/11": "20200101", + "0/40/12": "", + "0/40/13": "", + "0/40/14": "", + "0/40/15": "12345678", + "0/40/16": false, + "0/40/17": true, + "0/40/18": "mock-onoff-light", + "0/40/19": { + "caseSessionsPerFabric": 3, + "subscriptionsPerFabric": 3 + }, + "0/40/65532": 0, + "0/40/65533": 1, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, + 65528, 65529, 65531, 65532, 65533 + ], + "0/42/0": [], + "0/42/1": true, + "0/42/2": 0, + "0/42/3": 0, + "0/42/65532": 0, + "0/42/65533": 1, + "0/42/65528": [], + "0/42/65529": [0], + "0/42/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/43/0": "en-US", + "0/43/1": [ + "en-US", + "de-DE", + "fr-FR", + "en-GB", + "es-ES", + "zh-CN", + "it-IT", + "ja-JP" + ], + "0/43/65532": 0, + "0/43/65533": 1, + "0/43/65528": [], + "0/43/65529": [], + "0/43/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "0/44/0": 0, + "0/44/1": 0, + "0/44/2": [0, 1, 2, 3, 4, 5, 6, 8, 9, 10, 11, 7], + "0/44/65532": 0, + "0/44/65533": 1, + "0/44/65528": [], + "0/44/65529": [], + "0/44/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/48/0": 0, + "0/48/1": { + "failSafeExpiryLengthSeconds": 60, + "maxCumulativeFailsafeSeconds": 900 + }, + "0/48/2": 0, + "0/48/3": 0, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 1, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/49/0": 1, + "0/49/1": [ + { + "networkID": "", + "connected": true + } + ], + "0/49/2": 10, + "0/49/3": 30, + "0/49/4": true, + "0/49/5": 0, + "0/49/6": "", + "0/49/7": null, + "0/49/65532": 1, + "0/49/65533": 1, + "0/49/65528": [1, 5, 7], + "0/49/65529": [0, 2, 4, 6, 8], + "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], + "0/50/65532": 0, + "0/50/65533": 1, + "0/50/65528": [1], + "0/50/65529": [0], + "0/50/65531": [65528, 65529, 65531, 65532, 65533], + "0/51/0": [ + { + "name": "WIFI_STA_DEF", + "isOperational": true, + "offPremiseServicesReachableIPv4": null, + "offPremiseServicesReachableIPv6": null, + "hardwareAddress": "", + "IPv4Addresses": [""], + "IPv6Addresses": [], + "type": 1 + } + ], + "0/51/1": 6, + "0/51/2": 31279, + "0/51/3": 8, + "0/51/4": 1, + "0/51/5": [], + "0/51/6": [], + "0/51/7": [], + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 1, + "0/51/65528": [], + "0/51/65529": [0], + "0/51/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533 + ], + "0/52/0": [], + "0/52/1": 166480, + "0/52/2": 86512, + "0/52/3": 157052, + "0/52/65532": 0, + "0/52/65533": 1, + "0/52/65528": [], + "0/52/65529": [], + "0/52/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/53/65532": 15, + "0/53/65533": 1, + "0/53/65528": [], + "0/53/65529": [0], + "0/53/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, + 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, + 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, + 57, 58, 59, 60, 61, 62, 65528, 65529, 65531, 65532, 65533 + ], + "0/54/0": "", + "0/54/1": 4, + "0/54/2": 3, + "0/54/3": 6, + "0/54/4": -61, + "0/54/5": null, + "0/54/6": null, + "0/54/7": null, + "0/54/8": null, + "0/54/9": null, + "0/54/10": null, + "0/54/11": null, + "0/54/12": null, + "0/54/65532": 3, + "0/54/65533": 1, + "0/54/65528": [], + "0/54/65529": [0], + "0/54/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 65528, 65529, 65531, 65532, + 65533 + ], + "0/55/0": null, + "0/55/1": null, + "0/55/2": 0, + "0/55/3": 0, + "0/55/4": 0, + "0/55/5": 0, + "0/55/6": 0, + "0/55/7": null, + "0/55/8": 0, + "0/55/65532": 3, + "0/55/65533": 1, + "0/55/65528": [], + "0/55/65529": [0], + "0/55/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533 + ], + "0/59/65532": 0, + "0/59/65533": 1, + "0/59/65528": [], + "0/59/65529": [], + "0/59/65531": [65528, 65529, 65531, 65532, 65533], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 0, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 1, 2], + "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/62/0": [ + { + "noc": "", + "icac": "", + "fabricIndex": 1 + } + ], + "0/62/1": [ + { + "rootPublicKey": "", + "vendorId": 65521, + "fabricId": 1, + "nodeId": 1, + "label": "", + "fabricIndex": 1 + } + ], + "0/62/2": 5, + "0/62/3": 1, + "0/62/4": [""], + "0/62/5": 1, + "0/62/65532": 0, + "0/62/65533": 1, + "0/62/65528": [1, 3, 5, 8], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 3, + "0/63/3": 3, + "0/63/65532": 0, + "0/63/65533": 1, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/64/0": [ + { + "label": "room", + "value": "bedroom 2" + }, + { + "label": "orientation", + "value": "North" + }, + { + "label": "floor", + "value": "2" + }, + { + "label": "direction", + "value": "up" + } + ], + "0/64/65532": 0, + "0/64/65533": 1, + "0/64/65528": [], + "0/64/65529": [], + "0/64/65531": [0, 65528, 65529, 65531, 65532, 65533], + "0/65/0": [], + "0/65/65532": 0, + "0/65/65533": 1, + "0/65/65528": [], + "0/65/65529": [], + "0/65/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/3/0": 0, + "1/3/1": 0, + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/65528": [], + "1/3/65529": [0, 64], + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/4/0": 128, + "1/4/65532": 1, + "1/4/65533": 4, + "1/4/65528": [0, 1, 2, 3], + "1/4/65529": [0, 1, 2, 3, 4, 5], + "1/4/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/6/0": true, + "1/6/16384": true, + "1/6/16385": 0, + "1/6/16386": 0, + "1/6/16387": null, + "1/6/65532": 1, + "1/6/65533": 4, + "1/6/65528": [], + "1/6/65529": [0, 1, 2, 64, 65, 66], + "1/6/65531": [ + 0, 16384, 16385, 16386, 16387, 65528, 65529, 65531, 65532, 65533 + ], + "1/8/0": 52, + "1/8/1": 0, + "1/8/2": 1, + "1/8/3": 254, + "1/8/4": 0, + "1/8/5": 0, + "1/8/6": 0, + "1/8/15": 0, + "1/8/16": 0, + "1/8/17": null, + "1/8/18": 0, + "1/8/19": 0, + "1/8/20": 50, + "1/8/16384": null, + "1/8/65532": 3, + "1/8/65533": 5, + "1/8/65528": [], + "1/8/65529": [0, 1, 2, 3, 4, 5, 6, 7], + "1/8/65531": [ + 0, 1, 2, 3, 4, 5, 6, 15, 16, 17, 18, 19, 20, 16384, 65528, 65529, 65531, + 65532, 65533 + ], + "1/29/0": [ + { + "type": 257, + "revision": 1 + } + ], + "1/29/1": [3, 4, 6, 8, 29, 768, 1030], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65533": 1, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/768/0": 0, + "1/768/1": 0, + "1/768/2": 0, + "1/768/3": 24939, + "1/768/4": 24701, + "1/768/7": 0, + "1/768/8": 2, + "1/768/15": 0, + "1/768/16": 0, + "1/768/16384": 0, + "1/768/16385": 2, + "1/768/16386": 0, + "1/768/16387": 0, + "1/768/16388": 25, + "1/768/16389": 8960, + "1/768/16390": 0, + "1/768/16394": 31, + "1/768/16395": 0, + "1/768/16396": 65279, + "1/768/16397": 0, + "1/768/16400": 0, + "1/768/65532": 31, + "1/768/65533": 5, + "1/768/65528": [], + "1/768/65529": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 64, 65, 66, 67, 68, 71, 75, 76 + ], + "1/768/65531": [ + 0, 1, 2, 3, 4, 7, 8, 15, 16, 16384, 16385, 16386, 16387, 16388, 16389, + 16390, 16394, 16395, 16396, 16397, 16400, 65528, 65529, 65531, 65532, + 65533 + ], + "1/1030/0": 0, + "1/1030/1": 0, + "1/1030/2": 1, + "1/1030/65532": 0, + "1/1030/65533": 3, + "1/1030/65528": [], + "1/1030/65529": [], + "1/1030/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533] + }, + "available": true +} diff --git a/tests/components/matter/test_adapter.py b/tests/components/matter/test_adapter.py index 8eadb76894e4..9c37033dd4f2 100644 --- a/tests/components/matter/test_adapter.py +++ b/tests/components/matter/test_adapter.py @@ -8,6 +8,7 @@ from matter_server.common.helpers.util import dataclass_from_dict from matter_server.common.models import EventType, MatterNodeData import pytest +from homeassistant.components.matter.adapter import get_clean_name from homeassistant.components.matter.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -17,14 +18,24 @@ from .common import load_and_parse_node_fixture, setup_integration_with_node_fix # This tests needs to be adjusted to remove lingering tasks @pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize( + ("node_fixture", "name"), + [ + ("onoff-light", "Mock OnOff Light"), + ("onoff-light-alt-name", "Mock OnOff Light"), + ("onoff-light-no-name", "Mock Light"), + ], +) async def test_device_registry_single_node_device( hass: HomeAssistant, matter_client: MagicMock, + node_fixture: str, + name: str, ) -> None: """Test bridge devices are set up correctly with via_device.""" await setup_integration_with_node_fixture( hass, - "onoff-light", + node_fixture, matter_client, ) @@ -37,7 +48,7 @@ async def test_device_registry_single_node_device( # test serial id present as additional identifier assert (DOMAIN, "serial_12345678") in entry.identifiers - assert entry.name == "Mock OnOff Light" + assert entry.name == name assert entry.manufacturer == "Nabu Casa" assert entry.model == "Mock Light" assert entry.hw_version == "v1.0" @@ -145,3 +156,17 @@ async def test_node_added_subscription( entity_state = hass.states.get("light.mock_onoff_light") assert entity_state + + +async def test_get_clean_name_() -> None: + """Test get_clean_name helper. + + Test device names that are assigned to `null` + or have a trailing null char with spaces. + """ + assert get_clean_name(None) is None + assert get_clean_name("\x00") is None + assert get_clean_name(" \x00") is None + assert get_clean_name("") is None + assert get_clean_name("Mock device") == "Mock device" + assert get_clean_name("Mock device \x00") == "Mock device" From c63e3c3bf12e6404cb15d5edfea893f8fa2bbc0a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 25 May 2023 00:15:14 -0500 Subject: [PATCH 553/822] Update prometheus tests to avoid patching utcnow (#93505) --- tests/components/prometheus/test_init.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/components/prometheus/test_init.py b/tests/components/prometheus/test_init.py index e328487fa752..d9231732941e 100644 --- a/tests/components/prometheus/test_init.py +++ b/tests/components/prometheus/test_init.py @@ -5,6 +5,7 @@ from http import HTTPStatus from typing import Any from unittest import mock +from freezegun import freeze_time import prometheus_client import pytest @@ -941,10 +942,7 @@ async def sensor_fixture( suggested_object_id="radio_energy", original_name="Radio Energy", ) - with mock.patch( - "homeassistant.util.dt.utcnow", - return_value=datetime.datetime(1970, 1, 2, tzinfo=dt_util.UTC), - ): + with freeze_time(datetime.datetime(1970, 1, 2, tzinfo=dt_util.UTC)): set_state_with_entry(hass, sensor_3, 14) data["sensor_3"] = sensor_3 From f8d918ca4ad65d5f30949ca381538cb070043aa8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 25 May 2023 00:51:45 -0500 Subject: [PATCH 554/822] Small speedup to unifiprotect attribute lookups (#93507) --- .../components/unifiprotect/utils.py | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/unifiprotect/utils.py b/homeassistant/components/unifiprotect/utils.py index 3152213cce8c..061f6745f32d 100644 --- a/homeassistant/components/unifiprotect/utils.py +++ b/homeassistant/components/unifiprotect/utils.py @@ -41,18 +41,16 @@ from .const import ( def get_nested_attr(obj: Any, attr: str) -> Any: """Fetch a nested attribute.""" - attrs = attr.split(".") + if "." not in attr: + value = getattr(obj, attr, None) + else: + value = obj + for key in attr.split("."): + if not hasattr(value, key): + return None + value = getattr(value, key) - value = obj - for key in attrs: - if not hasattr(value, key): - return None - value = getattr(value, key) - - if isinstance(value, Enum): - value = value.value - - return value + return value.value if isinstance(value, Enum) else value @callback From 64118ee0118f436f454c0afdaea7aa88f6c6a6d4 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 25 May 2023 07:58:59 +0200 Subject: [PATCH 555/822] Improve hydrawise typing (#93503) * Improve hydrawise generic typing * Enable strict typing * Remove unused code --- .strict-typing | 1 + homeassistant/components/hydrawise/__init__.py | 10 +--------- homeassistant/components/hydrawise/entity.py | 14 +++++++------- homeassistant/components/hydrawise/switch.py | 3 +-- mypy.ini | 10 ++++++++++ 5 files changed, 20 insertions(+), 18 deletions(-) diff --git a/.strict-typing b/.strict-typing index 958c53d07dbb..47f01c6cae86 100644 --- a/.strict-typing +++ b/.strict-typing @@ -169,6 +169,7 @@ homeassistant.components.homekit_controller.utils homeassistant.components.homewizard.* homeassistant.components.http.* homeassistant.components.huawei_lte.* +homeassistant.components.hydrawise.* homeassistant.components.hyperion.* homeassistant.components.ibeacon.* homeassistant.components.image_processing.* diff --git a/homeassistant/components/hydrawise/__init__.py b/homeassistant/components/hydrawise/__init__.py index d5b69617ba9c..e09cabb74fc7 100644 --- a/homeassistant/components/hydrawise/__init__.py +++ b/homeassistant/components/hydrawise/__init__.py @@ -53,18 +53,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -def _show_failure_notification(hass: HomeAssistant, error: str): +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, ) - - -class HydrawiseHub: - """Representation of a base Hydrawise device.""" - - def __init__(self, data): - """Initialize the entity.""" - self.data = data diff --git a/homeassistant/components/hydrawise/entity.py b/homeassistant/components/hydrawise/entity.py index 405e5bc3fa33..98b66069913b 100644 --- a/homeassistant/components/hydrawise/entity.py +++ b/homeassistant/components/hydrawise/entity.py @@ -1,15 +1,15 @@ """Base classes for Hydrawise entities.""" +from __future__ import annotations from typing import Any from homeassistant.helpers.entity import EntityDescription -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import HydrawiseDataUpdateCoordinator -class HydrawiseEntity(CoordinatorEntity): +class HydrawiseEntity(CoordinatorEntity[HydrawiseDataUpdateCoordinator]): """Entity class for Hydrawise devices.""" _attr_attribution = "Data provided by hydrawise.com" @@ -18,7 +18,7 @@ class HydrawiseEntity(CoordinatorEntity): self, *, data: dict[str, Any], - coordinator: DataUpdateCoordinator, + coordinator: HydrawiseDataUpdateCoordinator, description: EntityDescription, ) -> None: """Initialize the Hydrawise entity.""" @@ -28,6 +28,6 @@ class HydrawiseEntity(CoordinatorEntity): self._attr_name = f"{self.data['name']} {description.name}" @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" return {"identifier": self.data.get("relay")} diff --git a/homeassistant/components/hydrawise/switch.py b/homeassistant/components/hydrawise/switch.py index 71c4f8df79ea..00089bb8774f 100644 --- a/homeassistant/components/hydrawise/switch.py +++ b/homeassistant/components/hydrawise/switch.py @@ -17,7 +17,6 @@ from homeassistant.core import HomeAssistant, callback 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 DataUpdateCoordinator from .const import ( ALLOWED_WATERING_TIME, @@ -90,7 +89,7 @@ class HydrawiseSwitch(HydrawiseEntity, SwitchEntity): self, *, data: dict[str, Any], - coordinator: DataUpdateCoordinator, + coordinator: HydrawiseDataUpdateCoordinator, description: SwitchEntityDescription, default_watering_timer: int, ) -> None: diff --git a/mypy.ini b/mypy.ini index feb6f5a72ea8..5e7d27586d1e 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1452,6 +1452,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.hydrawise.*] +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.hyperion.*] check_untyped_defs = true disallow_incomplete_defs = true From 6df44ff5d505ca4828496a2b04dcdb096e1298fe Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 25 May 2023 09:48:52 +0200 Subject: [PATCH 556/822] Bump actions/setup-python from 4.6.0 to 4.6.1 (#93512) Bumps [actions/setup-python](https://github.com/actions/setup-python) from 4.6.0 to 4.6.1. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v4.6.0...v4.6.1) --- updated-dependencies: - dependency-name: actions/setup-python dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 6 +++--- .github/workflows/ci.yaml | 26 +++++++++++++------------- .github/workflows/translations.yml | 2 +- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 94d4128bc660..fe0b2b5c8904 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -29,7 +29,7 @@ jobs: fetch-depth: 0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.6.0 + uses: actions/setup-python@v4.6.1 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -70,7 +70,7 @@ jobs: uses: actions/checkout@v3.5.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.6.0 + uses: actions/setup-python@v4.6.1 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -131,7 +131,7 @@ jobs: - name: Set up Python ${{ env.DEFAULT_PYTHON }} if: needs.init.outputs.channel == 'dev' - uses: actions/setup-python@v4.6.0 + uses: actions/setup-python@v4.6.1 with: python-version: ${{ env.DEFAULT_PYTHON }} diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 583be8f45865..d0438e674dd2 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -209,7 +209,7 @@ jobs: uses: actions/checkout@v3.5.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.6.0 + uses: actions/setup-python@v4.6.1 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -253,7 +253,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v3.5.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.6.0 + uses: actions/setup-python@v4.6.1 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -299,7 +299,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v3.5.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.6.0 + uses: actions/setup-python@v4.6.1 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -348,7 +348,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v3.5.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.6.0 + uses: actions/setup-python@v4.6.1 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -386,7 +386,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v3.5.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.6.0 + uses: actions/setup-python@v4.6.1 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -494,7 +494,7 @@ jobs: uses: actions/checkout@v3.5.2 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.6.0 + uses: actions/setup-python@v4.6.1 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -562,7 +562,7 @@ jobs: uses: actions/checkout@v3.5.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.6.0 + uses: actions/setup-python@v4.6.1 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -594,7 +594,7 @@ jobs: uses: actions/checkout@v3.5.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.6.0 + uses: actions/setup-python@v4.6.1 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -627,7 +627,7 @@ jobs: uses: actions/checkout@v3.5.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.6.0 + uses: actions/setup-python@v4.6.1 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -671,7 +671,7 @@ jobs: uses: actions/checkout@v3.5.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.6.0 + uses: actions/setup-python@v4.6.1 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -754,7 +754,7 @@ jobs: uses: actions/checkout@v3.5.2 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.6.0 + uses: actions/setup-python@v4.6.1 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -880,7 +880,7 @@ jobs: uses: actions/checkout@v3.5.2 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.6.0 + uses: actions/setup-python@v4.6.1 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -988,7 +988,7 @@ jobs: uses: actions/checkout@v3.5.2 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.6.0 + uses: actions/setup-python@v4.6.1 with: python-version: ${{ matrix.python-version }} check-latest: true diff --git a/.github/workflows/translations.yml b/.github/workflows/translations.yml index a18c050024b8..997543f0cf1c 100644 --- a/.github/workflows/translations.yml +++ b/.github/workflows/translations.yml @@ -22,7 +22,7 @@ jobs: uses: actions/checkout@v3.5.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.6.0 + uses: actions/setup-python@v4.6.1 with: python-version: ${{ env.DEFAULT_PYTHON }} From e74463216459636dd35dce5acba7457c1e994352 Mon Sep 17 00:00:00 2001 From: Matrix Date: Thu, 25 May 2023 16:44:51 +0800 Subject: [PATCH 557/822] YoLink Power Failure Alarm (#91934) * add ys-7106 * expose mute property * add volume entity description * add beep entity description * fix as suggest --- homeassistant/components/yolink/manifest.json | 2 +- homeassistant/components/yolink/sensor.py | 46 +++++++++++++++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 49 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/yolink/manifest.json b/homeassistant/components/yolink/manifest.json index 2353afe5f696..088ddd114f8f 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.2.8"] + "requirements": ["yolink-api==0.2.9"] } diff --git a/homeassistant/components/yolink/sensor.py b/homeassistant/components/yolink/sensor.py index 5f89f54ccbe7..75c4949859cb 100644 --- a/homeassistant/components/yolink/sensor.py +++ b/homeassistant/components/yolink/sensor.py @@ -14,6 +14,7 @@ from yolink.const import ( ATTR_DEVICE_MOTION_SENSOR, ATTR_DEVICE_MULTI_OUTLET, ATTR_DEVICE_OUTLET, + ATTR_DEVICE_POWER_FAILURE_ALARM, ATTR_DEVICE_SIREN, ATTR_DEVICE_SMART_REMOTER, ATTR_DEVICE_SWITCH, @@ -71,6 +72,7 @@ SENSOR_DEVICE_TYPE = [ ATTR_DEVICE_MULTI_OUTLET, ATTR_DEVICE_SMART_REMOTER, ATTR_DEVICE_OUTLET, + ATTR_DEVICE_POWER_FAILURE_ALARM, ATTR_DEVICE_SIREN, ATTR_DEVICE_SWITCH, ATTR_DEVICE_TH_SENSOR, @@ -86,6 +88,7 @@ BATTERY_POWER_SENSOR = [ ATTR_DEVICE_DOOR_SENSOR, ATTR_DEVICE_LEAK_SENSOR, ATTR_DEVICE_MOTION_SENSOR, + ATTR_DEVICE_POWER_FAILURE_ALARM, ATTR_DEVICE_SMART_REMOTER, ATTR_DEVICE_TH_SENSOR, ATTR_DEVICE_VIBRATION_SENSOR, @@ -110,6 +113,14 @@ def cvt_battery(val: int | None) -> int | None: return 0 +def cvt_volume(val: int | None) -> str | None: + """Convert volume to string.""" + if val is None: + return None + volume_level = {1: "low", 2: "medium", 3: "high"} + return volume_level.get(val, None) + + SENSOR_TYPES: tuple[YoLinkSensorEntityDescription, ...] = ( YoLinkSensorEntityDescription( key="battery", @@ -157,6 +168,41 @@ SENSOR_TYPES: tuple[YoLinkSensorEntityDescription, ...] = ( entity_registry_enabled_default=False, should_update_entity=lambda value: value is not None, ), + YoLinkSensorEntityDescription( + key="state", + device_class=SensorDeviceClass.ENUM, + name="Power failure alarm", + icon="mdi:flash", + options=["normal", "alert", "off"], + exists_fn=lambda device: device.device_type in ATTR_DEVICE_POWER_FAILURE_ALARM, + ), + YoLinkSensorEntityDescription( + key="mute", + device_class=SensorDeviceClass.ENUM, + name="Power failure alarm mute", + icon="mdi:volume-mute", + options=["muted", "unmuted"], + exists_fn=lambda device: device.device_type in ATTR_DEVICE_POWER_FAILURE_ALARM, + value=lambda value: "muted" if value is True else "unmuted", + ), + YoLinkSensorEntityDescription( + key="sound", + device_class=SensorDeviceClass.ENUM, + name="Power failure alarm volume", + icon="mdi:volume-high", + options=["low", "medium", "high"], + exists_fn=lambda device: device.device_type in ATTR_DEVICE_POWER_FAILURE_ALARM, + value=cvt_volume, + ), + YoLinkSensorEntityDescription( + key="beep", + device_class=SensorDeviceClass.ENUM, + name="Power failure alarm beep", + icon="mdi:bullhorn", + options=["enabled", "disabled"], + exists_fn=lambda device: device.device_type in ATTR_DEVICE_POWER_FAILURE_ALARM, + value=lambda value: "enabled" if value is True else "disabled", + ), ) diff --git a/requirements_all.txt b/requirements_all.txt index 3fa15da8b473..e3527fcf35f6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2712,7 +2712,7 @@ yeelight==0.7.10 yeelightsunflower==0.0.10 # homeassistant.components.yolink -yolink-api==0.2.8 +yolink-api==0.2.9 # homeassistant.components.youless youless-api==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ef90db4f5d4d..cd4f6be0b78a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1976,7 +1976,7 @@ yalexs==1.5.1 yeelight==0.7.10 # homeassistant.components.yolink -yolink-api==0.2.8 +yolink-api==0.2.9 # homeassistant.components.youless youless-api==1.0.1 From 6e8472b90fe4050c0ad73a39c21c3a05fbba1885 Mon Sep 17 00:00:00 2001 From: disaster37 Date: Thu, 25 May 2023 10:45:15 +0200 Subject: [PATCH 558/822] Fix/arest switch (#91553) * Fix arest switch to set output when connection is lost Signed-off-by: disaster37 * fix: lint Signed-off-by: disaster37 --------- Signed-off-by: disaster37 --- homeassistant/components/arest/switch.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/arest/switch.py b/homeassistant/components/arest/switch.py index 6efa24c5a0f5..1c67723fc028 100644 --- a/homeassistant/components/arest/switch.py +++ b/homeassistant/components/arest/switch.py @@ -161,16 +161,13 @@ class ArestSwitchFunction(ArestSwitchBase): class ArestSwitchPin(ArestSwitchBase): """Representation of an aREST switch. Based on digital I/O.""" - def __init__(self, resource, location, name, pin, invert): + def __init__(self, resource, location, name, pin, invert) -> None: """Initialize the switch.""" super().__init__(resource, location, name) self._pin = pin self.invert = invert - request = requests.get(f"{resource}/mode/{pin}/o", timeout=10) - if request.status_code != HTTPStatus.OK: - _LOGGER.error("Can't set mode") - self._attr_available = False + self.__set_pin_output() def turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" @@ -200,7 +197,15 @@ class ArestSwitchPin(ArestSwitchBase): request = requests.get(f"{self._resource}/digital/{self._pin}", timeout=10) status_value = int(self.invert) self._attr_is_on = request.json()["return_value"] != status_value - self._attr_available = True + if self._attr_available is False: + self._attr_available = True + self.__set_pin_output() except requests.exceptions.ConnectionError: _LOGGER.warning("No route to device %s", self._resource) self._attr_available = False + + def __set_pin_output(self) -> None: + request = requests.get(f"{self._resource}/mode/{self._pin}/o", timeout=10) + if request.status_code != HTTPStatus.OK: + _LOGGER.error("Can't set mode") + self._attr_available = False From 663f66a2b27fc557284c7cab575c5049406668e9 Mon Sep 17 00:00:00 2001 From: Dara Adib Date: Thu, 25 May 2023 04:46:34 -0400 Subject: [PATCH 559/822] Scrape Microsoft TTS supported languages (#91988) * Update Microsoft TTS supported languages `script.microsoft_tts` scrapes Microsoft Azure documentation for the list of supported languages and saves them to `homeassistant.generated.microsoft_tts` for use in the component. This adds support for more TTS languages, like fa-ir (Persian). * Improve xpath query for Microsoft TTS languages * Remove asserts for Microsoft TTS languages * Add more tests for Microsoft TTS languages --- homeassistant/components/microsoft/tts.py | 75 +---------- homeassistant/generated/microsoft_tts.py | 154 ++++++++++++++++++++++ script/microsoft_tts.py | 25 ++++ tests/components/microsoft/test_tts.py | 87 +++++++++++- 4 files changed, 265 insertions(+), 76 deletions(-) create mode 100644 homeassistant/generated/microsoft_tts.py create mode 100644 script/microsoft_tts.py diff --git a/homeassistant/components/microsoft/tts.py b/homeassistant/components/microsoft/tts.py index ff35fcb561f6..38f46d886ded 100644 --- a/homeassistant/components/microsoft/tts.py +++ b/homeassistant/components/microsoft/tts.py @@ -7,6 +7,7 @@ import voluptuous as vol from homeassistant.components.tts import CONF_LANG, PLATFORM_SCHEMA, Provider from homeassistant.const import CONF_API_KEY, CONF_REGION, CONF_TYPE, PERCENTAGE +from homeassistant.generated.microsoft_tts import SUPPORTED_LANGUAGES import homeassistant.helpers.config_validation as cv CONF_GENDER = "gender" @@ -17,80 +18,6 @@ CONF_PITCH = "pitch" CONF_CONTOUR = "contour" _LOGGER = logging.getLogger(__name__) -SUPPORTED_LANGUAGES = [ - "ar-eg", - "ar-sa", - "bg-bg", - "ca-es", - "cs-cz", - "cy-gb", - "da-dk", - "de-at", - "de-ch", - "de-de", - "el-gr", - "en-au", - "en-ca", - "en-gb", - "en-hk", - "en-ie", - "en-in", - "en-nz", - "en-ph", - "en-sg", - "en-us", - "en-za", - "es-ar", - "es-co", - "es-es", - "es-mx", - "es-us", - "et-ee", - "fi-fi", - "fr-be", - "fr-ca", - "fr-ch", - "fr-fr", - "ga-ie", - "gu-in", - "he-il", - "hi-in", - "hr-hr", - "hu-hu", - "id-id", - "is-is", - "it-it", - "ja-jp", - "ko-kr", - "lt-lt", - "lv-lv", - "mr-in", - "ms-my", - "mt-mt", - "nb-no", - "nl-be", - "nl-nl", - "pl-pl", - "pt-br", - "pt-pt", - "ro-ro", - "ru-ru", - "sk-sk", - "sl-si", - "sv-se", - "sw-ke", - "ta-in", - "te-in", - "th-th", - "tr-tr", - "uk-ua", - "ur-pk", - "vi-vn", - "zh-cn", - "zh-hk", - "zh-tw", -] - GENDERS = ["Female", "Male"] DEFAULT_LANG = "en-us" diff --git a/homeassistant/generated/microsoft_tts.py b/homeassistant/generated/microsoft_tts.py new file mode 100644 index 000000000000..2b894e834ee1 --- /dev/null +++ b/homeassistant/generated/microsoft_tts.py @@ -0,0 +1,154 @@ +"""Automatically generated file. + +To update, run python3 -m script.microsoft_tts +""" + +SUPPORTED_LANGUAGES = { + "af-za", + "am-et", + "ar-ae", + "ar-bh", + "ar-dz", + "ar-eg", + "ar-iq", + "ar-jo", + "ar-kw", + "ar-lb", + "ar-ly", + "ar-ma", + "ar-om", + "ar-qa", + "ar-sa", + "ar-sy", + "ar-tn", + "ar-ye", + "az-az", + "bg-bg", + "bn-bd", + "bn-in", + "bs-ba", + "ca-es", + "cs-cz", + "cy-gb", + "da-dk", + "de-at", + "de-ch", + "de-de", + "el-gr", + "en-au", + "en-ca", + "en-gb", + "en-hk", + "en-ie", + "en-in", + "en-ke", + "en-ng", + "en-nz", + "en-ph", + "en-sg", + "en-tz", + "en-us", + "en-za", + "es-ar", + "es-bo", + "es-cl", + "es-co", + "es-cr", + "es-cu", + "es-do", + "es-ec", + "es-es", + "es-gq", + "es-gt", + "es-hn", + "es-mx", + "es-ni", + "es-pa", + "es-pe", + "es-pr", + "es-py", + "es-sv", + "es-us", + "es-uy", + "es-ve", + "et-ee", + "eu-es", + "fa-ir", + "fi-fi", + "fil-ph", + "fr-be", + "fr-ca", + "fr-ch", + "fr-fr", + "ga-ie", + "gl-es", + "gu-in", + "he-il", + "hi-in", + "hr-hr", + "hu-hu", + "hy-am", + "id-id", + "is-is", + "it-it", + "ja-jp", + "jv-id", + "ka-ge", + "kk-kz", + "km-kh", + "kn-in", + "ko-kr", + "lo-la", + "lt-lt", + "lv-lv", + "mk-mk", + "ml-in", + "mn-mn", + "mr-in", + "ms-my", + "mt-mt", + "my-mm", + "nb-no", + "ne-np", + "nl-be", + "nl-nl", + "pl-pl", + "ps-af", + "pt-br", + "pt-pt", + "ro-ro", + "ru-ru", + "si-lk", + "sk-sk", + "sl-si", + "so-so", + "sq-al", + "sr-rs", + "su-id", + "sv-se", + "sw-ke", + "sw-tz", + "ta-in", + "ta-lk", + "ta-my", + "ta-sg", + "te-in", + "th-th", + "tr-tr", + "uk-ua", + "ur-in", + "ur-pk", + "uz-uz", + "vi-vn", + "wuu-cn", + "yue-cn", + "zh-cn", + "zh-cn-henan", + "zh-cn-liaoning", + "zh-cn-shaanxi", + "zh-cn-shandong", + "zh-cn-sichuan", + "zh-hk", + "zh-tw", + "zu-za", +} diff --git a/script/microsoft_tts.py b/script/microsoft_tts.py new file mode 100644 index 000000000000..128c287345c3 --- /dev/null +++ b/script/microsoft_tts.py @@ -0,0 +1,25 @@ +"""Helper script to update supported languages for Microsoft Text-to-Speech (TTS).""" +from pathlib import Path + +from lxml import html +import requests + +from .hassfest.serializer import format_python_namespace + +URL = "https://docs.microsoft.com/en-us/azure/cognitive-services/speech-service/language-support" +XPATH_QUERY = "//section[@data-tab='tts']/table[1]/tbody/tr/td[1]/code/text()" + +req = requests.get(URL) +req.raise_for_status() +tree = html.fromstring(req.content) +supported_languages_raw = tree.xpath(XPATH_QUERY) +supported_languages = {s.lower() for s in supported_languages_raw} + +Path("homeassistant/generated/microsoft_tts.py").write_text( + format_python_namespace( + { + "SUPPORTED_LANGUAGES": supported_languages, + }, + generator="script.microsoft_tts", + ) +) diff --git a/tests/components/microsoft/test_tts.py b/tests/components/microsoft/test_tts.py index 2bdc32832a83..f01eff556906 100644 --- a/tests/components/microsoft/test_tts.py +++ b/tests/components/microsoft/test_tts.py @@ -173,9 +173,92 @@ async def test_service_say_en_gb_service(hass: HomeAssistant, mock_tts, calls) - } +async def test_service_say_fa_ir_config(hass: HomeAssistant, mock_tts, calls) -> None: + """Test service call say with fa-ir code in the config.""" + + await async_setup_component( + hass, + tts.DOMAIN, + { + tts.DOMAIN: { + "platform": "microsoft", + "api_key": "", + "language": "fa-ir", + "type": "DilaraNeural", + } + }, + ) + + await hass.services.async_call( + tts.DOMAIN, + "microsoft_say", + { + "entity_id": "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + }, + blocking=True, + ) + + assert len(calls) == 1 + await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert len(mock_tts.mock_calls) == 2 + assert mock_tts.mock_calls[1][2] == { + "language": "fa-ir", + "gender": "Female", + "voiceType": "DilaraNeural", + "output": "audio-24khz-96kbitrate-mono-mp3", + "rate": "0%", + "volume": "0%", + "pitch": "default", + "contour": "", + "text": "There is a person at the front door.", + } + + +async def test_service_say_fa_ir_service(hass: HomeAssistant, mock_tts, calls) -> None: + """Test service call say with fa-ir code in the service.""" + + config = { + tts.DOMAIN: { + "platform": "microsoft", + "api_key": "", + "service_name": "microsoft_say", + } + } + + await async_setup_component(hass, tts.DOMAIN, config) + + await hass.services.async_call( + tts.DOMAIN, + "microsoft_say", + { + "entity_id": "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_LANGUAGE: "fa-ir", + tts.ATTR_OPTIONS: {"type": "DilaraNeural"}, + }, + blocking=True, + ) + + assert len(calls) == 1 + await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert len(mock_tts.mock_calls) == 2 + assert mock_tts.mock_calls[1][2] == { + "language": "fa-ir", + "gender": "Female", + "voiceType": "DilaraNeural", + "output": "audio-24khz-96kbitrate-mono-mp3", + "rate": "0%", + "volume": "0%", + "pitch": "default", + "contour": "", + "text": "There is a person at the front door.", + } + + def test_supported_languages() -> None: """Test list of supported languages.""" - for lang in ["en-us", "en-gb"]: + for lang in ["en-us", "fa-ir", "en-gb"]: assert lang in SUPPORTED_LANGUAGES assert "en-US" not in SUPPORTED_LANGUAGES for lang in [ @@ -187,7 +270,7 @@ def test_supported_languages() -> None: "en-us-jennyneural", ]: assert lang not in {s.lower() for s in SUPPORTED_LANGUAGES} - assert len(SUPPORTED_LANGUAGES) > 70 + assert len(SUPPORTED_LANGUAGES) > 100 async def test_invalid_language(hass: HomeAssistant, mock_tts, calls) -> None: From 6cd766ef1fde4d98091c546f604e4321e0e00ec0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Thu, 25 May 2023 11:04:57 +0200 Subject: [PATCH 560/822] Add Airzone Cloud diagnostics (#93465) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * airzone_cloud: add diagnostics support Signed-off-by: Álvaro Fernández Rojas * airzone_cloue: remove unused import Signed-off-by: Álvaro Fernández Rojas * airzone_cloud: diagnostics: redact additional API keys Signed-off-by: Álvaro Fernández Rojas --------- Signed-off-by: Álvaro Fernández Rojas --- .../components/airzone_cloud/diagnostics.py | 138 ++++++++++++++++++ .../airzone_cloud/test_diagnostics.py | 116 +++++++++++++++ tests/components/airzone_cloud/util.py | 2 +- 3 files changed, 255 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/airzone_cloud/diagnostics.py create mode 100644 tests/components/airzone_cloud/test_diagnostics.py diff --git a/homeassistant/components/airzone_cloud/diagnostics.py b/homeassistant/components/airzone_cloud/diagnostics.py new file mode 100644 index 000000000000..a52fb66ca8f4 --- /dev/null +++ b/homeassistant/components/airzone_cloud/diagnostics.py @@ -0,0 +1,138 @@ +"""Support for the Airzone Cloud diagnostics.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from aioairzone_cloud.const import ( + API_STAT_AP_MAC, + API_STAT_SSID, + AZD_WIFI_MAC, + RAW_DEVICES_STATUS, + RAW_INSTALLATIONS, + RAW_WEBSERVERS, +) + +from homeassistant.components.diagnostics.util import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import AirzoneUpdateCoordinator + +TO_REDACT_API = [ + "_id", + "city", + "group_id", + "location_id", + "pin", + "user_id", + API_STAT_AP_MAC, + API_STAT_SSID, +] + +TO_REDACT_CONFIG = [ + CONF_PASSWORD, + CONF_USERNAME, +] + +TO_REDACT_COORD = [ + AZD_WIFI_MAC, +] + + +def gather_ids(api_data: dict[str, Any]) -> dict[str, Any]: + """Return dict with IDs.""" + ids: dict[str, Any] = {} + + dev_idx = 1 + for dev_id in api_data[RAW_DEVICES_STATUS]: + if dev_id not in ids: + ids[dev_id] = f"device{dev_idx}" + dev_idx += 1 + + inst_idx = 1 + for inst_id in api_data[RAW_INSTALLATIONS]: + if inst_id not in ids: + ids[inst_id] = f"installation{inst_idx}" + inst_idx += 1 + + ws_idx = 1 + for ws_id in api_data[RAW_WEBSERVERS]: + if ws_id not in ids: + ids[ws_id] = f"webserver{ws_idx}" + ws_idx += 1 + + return ids + + +def redact_keys(data: Any, ids: dict[str, Any]) -> Any: + """Redact sensitive keys in a dict.""" + if not isinstance(data, (Mapping, list)): + return data + + if isinstance(data, list): + return [redact_keys(val, ids) for val in data] + + redacted = {**data} + + keys = list(redacted) + for key in keys: + if key in ids: + redacted[ids[key]] = redacted.pop(key) + elif isinstance(redacted[key], Mapping): + redacted[key] = redact_keys(redacted[key], ids) + elif isinstance(redacted[key], list): + redacted[key] = [redact_keys(item, ids) for item in redacted[key]] + + return redacted + + +def redact_values(data: Any, ids: dict[str, Any]) -> Any: + """Redact sensitive values in a dict.""" + if not isinstance(data, (Mapping, list)): + if data in ids: + return ids[data] + return data + + if isinstance(data, list): + return [redact_values(val, ids) for val in data] + + redacted = {**data} + + for key, value in redacted.items(): + if value is None: + continue + if isinstance(value, Mapping): + redacted[key] = redact_values(value, ids) + elif isinstance(value, list): + redacted[key] = [redact_values(item, ids) for item in value] + elif value in ids: + redacted[key] = ids[value] + + return redacted + + +def redact_all( + data: dict[str, Any], ids: dict[str, Any], to_redact: list[str] +) -> dict[str, Any]: + """Redact sensitive data.""" + _data = redact_keys(data, ids) + _data = redact_values(_data, ids) + return async_redact_data(_data, to_redact) + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator: AirzoneUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + raw_data = coordinator.airzone.raw_data() + ids = gather_ids(raw_data) + + return { + "api_data": redact_all(raw_data, ids, TO_REDACT_API), + "config_entry": redact_all(config_entry.as_dict(), ids, TO_REDACT_CONFIG), + "coord_data": redact_all(coordinator.data, ids, TO_REDACT_COORD), + } diff --git a/tests/components/airzone_cloud/test_diagnostics.py b/tests/components/airzone_cloud/test_diagnostics.py new file mode 100644 index 000000000000..a007886c9555 --- /dev/null +++ b/tests/components/airzone_cloud/test_diagnostics.py @@ -0,0 +1,116 @@ +"""The diagnostics tests for the Airzone Cloud platform.""" + +from unittest.mock import patch + +from aioairzone_cloud.const import ( + API_DEVICE_ID, + API_DEVICES, + API_GROUPS, + API_WS_ID, + AZD_INSTALLATIONS, + AZD_SYSTEMS, + AZD_WEBSERVERS, + AZD_ZONES, + RAW_DEVICES_CONFIG, + RAW_DEVICES_STATUS, + RAW_INSTALLATIONS, + RAW_INSTALLATIONS_LIST, + RAW_WEBSERVERS, +) + +from homeassistant.components.airzone_cloud.const import DOMAIN +from homeassistant.components.diagnostics import REDACTED +from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from .util import CONFIG, WS_ID, async_init_integration + +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + +RAW_DATA_MOCK = { + RAW_DEVICES_CONFIG: { + "dev1": {}, + }, + RAW_DEVICES_STATUS: { + "dev1": {}, + }, + RAW_INSTALLATIONS: { + CONFIG[CONF_ID]: { + API_GROUPS: [ + { + API_DEVICES: [ + { + API_DEVICE_ID: "device1", + API_WS_ID: WS_ID, + }, + ], + }, + ], + "plugins": { + "schedules": { + "calendar_ws_ids": [ + WS_ID, + ], + }, + }, + }, + }, + RAW_INSTALLATIONS_LIST: {}, + RAW_WEBSERVERS: { + WS_ID: {}, + }, + "test_cov": { + "1": None, + "2": ["foo", "bar"], + "3": [ + [ + "foo", + "bar", + ], + ], + }, +} + + +async def test_config_entry_diagnostics( + hass: HomeAssistant, hass_client: ClientSessionGenerator +) -> None: + """Test config entry diagnostics.""" + await async_init_integration(hass) + assert hass.data[DOMAIN] + + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.raw_data", + return_value=RAW_DATA_MOCK, + ): + diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + + assert list(diag["api_data"]) >= list(RAW_DATA_MOCK) + assert "dev1" not in diag["api_data"][RAW_DEVICES_CONFIG] + assert "device1" in diag["api_data"][RAW_DEVICES_CONFIG] + assert "inst1" not in diag["api_data"][RAW_INSTALLATIONS] + assert "installation1" in diag["api_data"][RAW_INSTALLATIONS] + assert WS_ID not in diag["api_data"][RAW_WEBSERVERS] + assert "webserver1" in diag["api_data"][RAW_WEBSERVERS] + + assert ( + diag["config_entry"].items() + >= { + "data": { + CONF_ID: "installation1", + CONF_PASSWORD: REDACTED, + CONF_USERNAME: REDACTED, + }, + "domain": DOMAIN, + "unique_id": "installation1", + }.items() + ) + + assert list(diag["coord_data"]) >= [ + AZD_INSTALLATIONS, + AZD_SYSTEMS, + AZD_WEBSERVERS, + AZD_ZONES, + ] diff --git a/tests/components/airzone_cloud/util.py b/tests/components/airzone_cloud/util.py index c88221fc5cf3..06e5d1b30170 100644 --- a/tests/components/airzone_cloud/util.py +++ b/tests/components/airzone_cloud/util.py @@ -151,7 +151,7 @@ async def async_init_integration( config_entry = MockConfigEntry( data=CONFIG, domain=DOMAIN, - unique_id="airzone_cloud_unique_id", + unique_id=CONFIG[CONF_ID], ) config_entry.add_to_hass(hass) From 1b5d207984993eef92a48ec6feb5c12a89bd3db3 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 25 May 2023 11:05:25 +0200 Subject: [PATCH 561/822] Add imap custom event data template (#93423) * Add imap custom event template * Add template validation --- homeassistant/components/imap/__init__.py | 2 +- homeassistant/components/imap/config_flow.py | 32 ++++++++--- homeassistant/components/imap/const.py | 1 + homeassistant/components/imap/coordinator.py | 60 ++++++++++++++++---- homeassistant/components/imap/strings.json | 4 +- tests/components/imap/test_config_flow.py | 25 +++++++- tests/components/imap/test_init.py | 54 ++++++++++++++++++ 7 files changed, 155 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/imap/__init__.py b/homeassistant/components/imap/__init__.py index 468181be5f7c..04069d42d7d2 100644 --- a/homeassistant/components/imap/__init__.py +++ b/homeassistant/components/imap/__init__.py @@ -45,7 +45,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator_class = ImapPollingDataUpdateCoordinator coordinator: ImapPushDataUpdateCoordinator | ImapPollingDataUpdateCoordinator = ( - coordinator_class(hass, imap_client) + coordinator_class(hass, imap_client, entry) ) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/imap/config_flow.py b/homeassistant/components/imap/config_flow.py index 8724dbf97c01..3a7f3d9d7bdd 100644 --- a/homeassistant/components/imap/config_flow.py +++ b/homeassistant/components/imap/config_flow.py @@ -11,18 +11,24 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_PORT, CONF_USERNAME -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import AbortFlow, FlowResult +from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.selector import ( SelectSelector, SelectSelectorConfig, SelectSelectorMode, + TextSelector, + TextSelectorConfig, + TextSelectorType, ) +from homeassistant.helpers.template import Template from homeassistant.util.ssl import SSLCipherList from .const import ( CONF_CHARSET, + CONF_CUSTOM_EVENT_DATA_TEMPLATE, CONF_FOLDER, CONF_MAX_MESSAGE_SIZE, CONF_SEARCH, @@ -43,6 +49,9 @@ CIPHER_SELECTOR = SelectSelector( translation_key=CONF_SSL_CIPHER_LIST, ) ) +TEMPLATE_SELECTOR = TextSelector( + TextSelectorConfig(type=TextSelectorType.TEXT, multiline=True) +) CONFIG_SCHEMA = vol.Schema( { @@ -69,14 +78,17 @@ OPTIONS_SCHEMA = vol.Schema( ) OPTIONS_SCHEMA_ADVANCED = { + vol.Optional(CONF_CUSTOM_EVENT_DATA_TEMPLATE): TEMPLATE_SELECTOR, vol.Optional(CONF_MAX_MESSAGE_SIZE, default=DEFAULT_MAX_MESSAGE_SIZE): vol.All( cv.positive_int, vol.Range(min=DEFAULT_MAX_MESSAGE_SIZE, max=MAX_MESSAGE_SIZE_LIMIT), - ) + ), } -async def validate_input(user_input: dict[str, Any]) -> dict[str, str]: +async def validate_input( + hass: HomeAssistant, user_input: dict[str, Any] +) -> dict[str, str]: """Validate user input.""" errors = {} @@ -104,6 +116,12 @@ async def validate_input(user_input: dict[str, Any]) -> dict[str, str]: errors[CONF_CHARSET] = "invalid_charset" else: errors[CONF_SEARCH] = "invalid_search" + if template := user_input.get(CONF_CUSTOM_EVENT_DATA_TEMPLATE): + try: + Template(template, hass=hass).ensure_valid() + except TemplateError: + errors[CONF_CUSTOM_EVENT_DATA_TEMPLATE] = "invalid_template" + return errors @@ -131,7 +149,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): } ) title = user_input[CONF_NAME] - if await validate_input(data): + if await validate_input(self.hass, data): raise AbortFlow("cannot_connect") return self.async_create_entry(title=title, data=data) @@ -154,7 +172,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): } ) - if not (errors := await validate_input(user_input)): + if not (errors := await validate_input(self.hass, user_input)): title = user_input[CONF_USERNAME] return self.async_create_entry(title=title, data=user_input) @@ -177,7 +195,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): assert self._reauth_entry if user_input is not None: user_input = {**self._reauth_entry.data, **user_input} - if not (errors := await validate_input(user_input)): + if not (errors := await validate_input(self.hass, user_input)): self.hass.config_entries.async_update_entry( self._reauth_entry, data=user_input ) @@ -231,7 +249,7 @@ class OptionsFlow(config_entries.OptionsFlowWithConfigEntry): errors = {"base": err.reason} else: entry_data.update(user_input) - errors = await validate_input(entry_data) + errors = await validate_input(self.hass, entry_data) if not errors: self.hass.config_entries.async_update_entry( self.config_entry, data=entry_data diff --git a/homeassistant/components/imap/const.py b/homeassistant/components/imap/const.py index b39c88086331..2e36dd41e168 100644 --- a/homeassistant/components/imap/const.py +++ b/homeassistant/components/imap/const.py @@ -9,6 +9,7 @@ CONF_FOLDER: Final = "folder" CONF_SEARCH: Final = "search" CONF_CHARSET: Final = "charset" CONF_MAX_MESSAGE_SIZE = "max_message_size" +CONF_CUSTOM_EVENT_DATA_TEMPLATE: Final = "custom_event_data_template" CONF_SSL_CIPHER_LIST: Final = "ssl_cipher_list" DEFAULT_PORT: Final = 993 diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index 512df9adf51c..b478d475f9a6 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -19,13 +19,19 @@ from homeassistant.const import ( CONTENT_TYPE_TEXT_PLAIN, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryError +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryError, + TemplateError, +) from homeassistant.helpers.json import json_bytes +from homeassistant.helpers.template import Template from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util.ssl import SSLCipherList, client_context from .const import ( CONF_CHARSET, + CONF_CUSTOM_EVENT_DATA_TEMPLATE, CONF_FOLDER, CONF_MAX_MESSAGE_SIZE, CONF_SEARCH, @@ -145,16 +151,22 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): """Base class for imap client.""" config_entry: ConfigEntry + custom_event_template: Template | None def __init__( self, hass: HomeAssistant, imap_client: IMAP4_SSL, + entry: ConfigEntry, update_interval: timedelta | None, ) -> None: """Initiate imap client.""" self.imap_client = imap_client self._last_message_id: str | None = None + self.custom_event_template = None + _custom_event_template = entry.data.get(CONF_CUSTOM_EVENT_DATA_TEMPLATE) + if _custom_event_template is not None: + self.custom_event_template = Template(_custom_event_template, hass=hass) super().__init__( hass, _LOGGER, @@ -181,15 +193,36 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): "search": self.config_entry.data[CONF_SEARCH], "folder": self.config_entry.data[CONF_FOLDER], "date": message.date, - "text": message.text[ - : self.config_entry.data.get( - CONF_MAX_MESSAGE_SIZE, DEFAULT_MAX_MESSAGE_SIZE - ) - ], + "text": message.text, "sender": message.sender, "subject": message.subject, "headers": message.headers, } + if self.custom_event_template is not None: + try: + data["custom"] = self.custom_event_template.async_render( + data, parse_result=True + ) + _LOGGER.debug( + "imap custom template (%s) for msgid %s rendered to: %s", + self.custom_event_template, + last_message_id, + data["custom"], + ) + except TemplateError as err: + data["custom"] = None + _LOGGER.error( + "Error rendering imap custom template (%s) for msgid %s " + "failed with message: %s", + self.custom_event_template, + last_message_id, + err, + ) + data["text"] = message.text[ + : self.config_entry.data.get( + CONF_MAX_MESSAGE_SIZE, DEFAULT_MAX_MESSAGE_SIZE + ) + ] if (size := len(json_bytes(data))) > MAX_EVENT_DATA_BYTES: _LOGGER.warning( "Custom imap_content event skipped, size (%s) exceeds " @@ -203,7 +236,8 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): self.hass.bus.fire(EVENT_IMAP, data) _LOGGER.debug( - "Message processed, sender: %s, subject: %s", + "Message with id %s processed, sender: %s, subject: %s", + last_message_id, message.sender, message.subject, ) @@ -260,9 +294,11 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): class ImapPollingDataUpdateCoordinator(ImapDataUpdateCoordinator): """Class for imap client.""" - def __init__(self, hass: HomeAssistant, imap_client: IMAP4_SSL) -> None: + def __init__( + self, hass: HomeAssistant, imap_client: IMAP4_SSL, entry: ConfigEntry + ) -> None: """Initiate imap client.""" - super().__init__(hass, imap_client, timedelta(seconds=10)) + super().__init__(hass, imap_client, entry, timedelta(seconds=10)) async def _async_update_data(self) -> int | None: """Update the number of unread emails.""" @@ -291,9 +327,11 @@ class ImapPollingDataUpdateCoordinator(ImapDataUpdateCoordinator): class ImapPushDataUpdateCoordinator(ImapDataUpdateCoordinator): """Class for imap client.""" - def __init__(self, hass: HomeAssistant, imap_client: IMAP4_SSL) -> None: + def __init__( + self, hass: HomeAssistant, imap_client: IMAP4_SSL, entry: ConfigEntry + ) -> None: """Initiate imap client.""" - super().__init__(hass, imap_client, None) + super().__init__(hass, imap_client, entry, None) self._push_wait_task: asyncio.Task[None] | None = None async def _async_update_data(self) -> int | None: diff --git a/homeassistant/components/imap/strings.json b/homeassistant/components/imap/strings.json index 6e97fbe69d84..b640b3f75152 100644 --- a/homeassistant/components/imap/strings.json +++ b/homeassistant/components/imap/strings.json @@ -40,6 +40,7 @@ "data": { "folder": "[%key:component::imap::config::step::user::data::folder%]", "search": "[%key:component::imap::config::step::user::data::search%]", + "custom_event_data_template": "Template to create custom event data", "max_message_size": "Max message size (2048 < size < 30000)" } } @@ -50,7 +51,8 @@ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "invalid_charset": "[%key:component::imap::config::error::invalid_charset%]", "invalid_folder": "[%key:component::imap::config::error::invalid_folder%]", - "invalid_search": "[%key:component::imap::config::error::invalid_search%]" + "invalid_search": "[%key:component::imap::config::error::invalid_search%]", + "invalid_template": "Invalid template" } }, "selector": { diff --git a/tests/components/imap/test_config_flow.py b/tests/components/imap/test_config_flow.py index b8242a87be84..d0ab4822b67a 100644 --- a/tests/components/imap/test_config_flow.py +++ b/tests/components/imap/test_config_flow.py @@ -404,6 +404,21 @@ async def test_key_options_in_options_form(hass: HomeAssistant) -> None: ({"max_message_size": "8192"}, data_entry_flow.FlowResultType.CREATE_ENTRY), ({"max_message_size": "1024"}, data_entry_flow.FlowResultType.FORM), ({"max_message_size": "65536"}, data_entry_flow.FlowResultType.FORM), + ( + {"custom_event_data_template": "{{ subject }}"}, + data_entry_flow.FlowResultType.CREATE_ENTRY, + ), + ( + {"custom_event_data_template": "{{ invalid_syntax"}, + data_entry_flow.FlowResultType.FORM, + ), + ], + ids=[ + "valid_message_size", + "invalid_message_size_low", + "invalid_message_size_high", + "valid_template", + "invalid_template", ], ) async def test_advanced_options_form( @@ -438,9 +453,13 @@ async def test_advanced_options_form( result["flow_id"], new_config ) assert result2["type"] == assert_result - # Check if entry was updated - for key, value in new_config.items(): - assert str(entry.data[key]) == value + + if result2.get("errors") is not None: + assert assert_result == data_entry_flow.FlowResultType.FORM + else: + # Check if entry was updated + for key, value in new_config.items(): + assert str(entry.data[key]) == value except vol.MultipleInvalid: # Check if form was expected with these options assert assert_result == data_entry_flow.FlowResultType.FORM diff --git a/tests/components/imap/test_init.py b/tests/components/imap/test_init.py index 26c0325a50c2..801ce0504324 100644 --- a/tests/components/imap/test_init.py +++ b/tests/components/imap/test_init.py @@ -505,3 +505,57 @@ async def test_message_is_truncated( event_data = event_called[0].data assert len(event_data["text"]) == 3 + + +@pytest.mark.parametrize( + ("imap_search", "imap_fetch"), + [(TEST_SEARCH_RESPONSE, TEST_FETCH_RESPONSE_TEXT_PLAIN)], + ids=["plain"], +) +@pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) +@pytest.mark.parametrize( + ("custom_template", "result", "error"), + [ + ("{{ subject }}", "Test subject", None), + ('{{ "@example.com" in sender }}', True, None), + ("{% bad template }}", None, "Error rendering imap custom template"), + ], + ids=["subject_test", "sender_filter", "template_error"], +) +async def test_custom_template( + hass: HomeAssistant, + mock_imap_protocol: MagicMock, + caplog: pytest.LogCaptureFixture, + custom_template: str, + result: str | bool | None, + error: str | None, +) -> None: + """Test the custom template event data.""" + event_called = async_capture_events(hass, "imap_content") + + config = MOCK_CONFIG.copy() + config["custom_event_data_template"] = custom_template + config_entry = MockConfigEntry(domain=DOMAIN, data=config) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + # Make sure we have had one update (when polling) + async_fire_time_changed(hass, utcnow() + timedelta(seconds=5)) + await hass.async_block_till_done() + state = hass.states.get("sensor.imap_email_email_com") + # we should have received one message + assert state is not None + assert state.state == "1" + + # we should have received one event + assert len(event_called) == 1 + data: dict[str, Any] = event_called[0].data + assert data["server"] == "imap.server.com" + assert data["username"] == "email@email.com" + assert data["search"] == "UnSeen UnDeleted" + assert data["folder"] == "INBOX" + assert data["sender"] == "john.doe@example.com" + assert data["subject"] == "Test subject" + assert data["text"] + assert data["custom"] == result + assert error in caplog.text if error is not None else True From f86cc34644ea97f823ffb666185a0efedca874e3 Mon Sep 17 00:00:00 2001 From: RenierM26 <66512715+RenierM26@users.noreply.github.com> Date: Thu, 25 May 2023 11:09:47 +0200 Subject: [PATCH 562/822] Add Ezviz number entity (#93366) * Initial commit * Add number entity. * update coveragerc * Add services back and add depreciation repair. * Remove redundant typing declaration. * Case change in strings. * Apply cleanups from simular pull request. * Commit suggestions. --- .coveragerc | 1 + homeassistant/components/ezviz/__init__.py | 1 + homeassistant/components/ezviz/camera.py | 16 +++- homeassistant/components/ezviz/number.py | 98 +++++++++++++++++++++ homeassistant/components/ezviz/sensor.py | 1 - homeassistant/components/ezviz/strings.json | 6 ++ 6 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/ezviz/number.py diff --git a/.coveragerc b/.coveragerc index 30df1694a25c..928061407b8c 100644 --- a/.coveragerc +++ b/.coveragerc @@ -327,6 +327,7 @@ omit = homeassistant/components/ezviz/binary_sensor.py homeassistant/components/ezviz/camera.py homeassistant/components/ezviz/coordinator.py + homeassistant/components/ezviz/number.py homeassistant/components/ezviz/entity.py homeassistant/components/ezviz/sensor.py homeassistant/components/ezviz/switch.py diff --git a/homeassistant/components/ezviz/__init__.py b/homeassistant/components/ezviz/__init__.py index 05857abbac7b..2966c339f957 100644 --- a/homeassistant/components/ezviz/__init__.py +++ b/homeassistant/components/ezviz/__init__.py @@ -35,6 +35,7 @@ PLATFORMS_BY_TYPE: dict[str, list] = { ATTR_TYPE_CLOUD: [ Platform.BINARY_SENSOR, Platform.CAMERA, + Platform.NUMBER, Platform.SENSOR, Platform.SWITCH, Platform.UPDATE, diff --git a/homeassistant/components/ezviz/camera.py b/homeassistant/components/ezviz/camera.py index 0456e7ade9e6..57be995a4892 100644 --- a/homeassistant/components/ezviz/camera.py +++ b/homeassistant/components/ezviz/camera.py @@ -17,7 +17,11 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv, discovery_flow +from homeassistant.helpers import ( + config_validation as cv, + discovery_flow, + issue_registry as ir, +) from homeassistant.helpers.entity_platform import ( AddEntitiesCallback, async_get_current_platform, @@ -303,3 +307,13 @@ class EzvizCamera(EzvizEntity, Camera): ) except (HTTPError, PyEzvizError) as err: raise PyEzvizError("Cannot set detection sensitivity level") from err + + ir.async_create_issue( + self.hass, + DOMAIN, + "service_depreciation_detection_sensibility", + breaks_in_ha_version="2023.8.0", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="service_depreciation_detection_sensibility", + ) diff --git a/homeassistant/components/ezviz/number.py b/homeassistant/components/ezviz/number.py new file mode 100644 index 000000000000..849bf2c400b6 --- /dev/null +++ b/homeassistant/components/ezviz/number.py @@ -0,0 +1,98 @@ +"""Support for EZVIZ number controls.""" +from __future__ import annotations + +from pyezviz.constants import DeviceCatagories +from pyezviz.exceptions import HTTPError, PyEzvizError + +from homeassistant.components.number import NumberEntity, NumberEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DATA_COORDINATOR, DOMAIN +from .coordinator import EzvizDataUpdateCoordinator +from .entity import EzvizEntity + +PARALLEL_UPDATES = 1 + +NUMBER_TYPES = NumberEntityDescription( + key="detection_sensibility", + name="Detection sensitivity", + icon="mdi:eye", + entity_category=EntityCategory.CONFIG, + native_min_value=0, + native_step=1, +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up EZVIZ sensors based on a config entry.""" + coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ + DATA_COORDINATOR + ] + + async_add_entities( + EzvizSensor(coordinator, camera, sensor, NUMBER_TYPES) + for camera in coordinator.data + for sensor, value in coordinator.data[camera].items() + if sensor in NUMBER_TYPES.key + if value + ) + + +class EzvizSensor(EzvizEntity, NumberEntity): + """Representation of a EZVIZ number entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: EzvizDataUpdateCoordinator, + serial: str, + sensor: str, + description: NumberEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, serial) + self._sensor_name = sensor + self.battery_cam_type = bool( + self.data["device_category"] + == DeviceCatagories.BATTERY_CAMERA_DEVICE_CATEGORY.value + ) + self._attr_unique_id = f"{serial}_{sensor}" + self._attr_native_max_value = 100 if self.battery_cam_type else 6 + self.entity_description = description + + @property + def native_value(self) -> float | None: + """Return the state of the entity.""" + try: + return float(self.data[self._sensor_name]) + except ValueError: + return None + + def set_native_value(self, value: float) -> None: + """Set camera detection sensitivity.""" + level = int(value) + try: + if self.battery_cam_type: + self.coordinator.ezviz_client.detection_sensibility( + self._serial, + level, + 3, + ) + else: + self.coordinator.ezviz_client.detection_sensibility( + self._serial, + level, + 0, + ) + + except (HTTPError, PyEzvizError) as err: + raise HomeAssistantError( + f"Cannot set detection sensitivity level on {self.name}" + ) from err diff --git a/homeassistant/components/ezviz/sensor.py b/homeassistant/components/ezviz/sensor.py index 8e617aa3b3eb..11412c1fc70d 100644 --- a/homeassistant/components/ezviz/sensor.py +++ b/homeassistant/components/ezviz/sensor.py @@ -25,7 +25,6 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = { device_class=SensorDeviceClass.BATTERY, ), "alarm_sound_mod": SensorEntityDescription(key="alarm_sound_mod"), - "detection_sensibility": SensorEntityDescription(key="detection_sensibility"), "last_alarm_time": SensorEntityDescription(key="last_alarm_time"), "Seconds_Last_Trigger": SensorEntityDescription( key="Seconds_Last_Trigger", diff --git a/homeassistant/components/ezviz/strings.json b/homeassistant/components/ezviz/strings.json index 5e258e427057..6f00568cf2b6 100644 --- a/homeassistant/components/ezviz/strings.json +++ b/homeassistant/components/ezviz/strings.json @@ -58,5 +58,11 @@ } } } + }, + "issues": { + "service_depreciation_detection_sensibility": { + "title": "Ezviz Detection sensitivity service is being removed", + "description": "Ezviz Detection sensitivity service is deprecated and will be removed in Home Assistant 2023.8; Please adjust the automation or script that uses the service and select submit below to mark this issue as resolved." + } } } From 4ef315b32a36cbef761f6330c06479469b188259 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 25 May 2023 11:11:29 +0200 Subject: [PATCH 563/822] Add Kodi keypress event (#93321) * Add keypress events * Fix styling * fix tests * improve tests * Revert device trigger * Add entity_id to event for ease of filtering --- homeassistant/components/kodi/media_player.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/homeassistant/components/kodi/media_player.py b/homeassistant/components/kodi/media_player.py index 3272491a06d3..86788db6ae60 100644 --- a/homeassistant/components/kodi/media_player.py +++ b/homeassistant/components/kodi/media_player.py @@ -26,6 +26,7 @@ from homeassistant.components.media_player import ( from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, + CONF_DEVICE_ID, CONF_HOST, CONF_NAME, CONF_PASSWORD, @@ -33,6 +34,7 @@ from homeassistant.const import ( CONF_PROXY_SSL, CONF_SSL, CONF_TIMEOUT, + CONF_TYPE, CONF_USERNAME, EVENT_HOMEASSISTANT_STARTED, ) @@ -279,6 +281,7 @@ class KodiEntity(MediaPlayerEntity): self._connection = connection self._kodi = kodi self._unique_id = uid + self._device_id = None self._players = None self._properties = {} self._item = {} @@ -336,6 +339,20 @@ class KodiEntity(MediaPlayerEntity): self._app_properties["muted"] = data["muted"] self.async_write_ha_state() + @callback + def async_on_key_press(self, sender, data): + """Handle a incoming key press notification.""" + self.hass.bus.async_fire( + f"{DOMAIN}_keypress", + { + CONF_TYPE: "keypress", + CONF_DEVICE_ID: self._device_id, + ATTR_ENTITY_ID: self.entity_id, + "sender": sender, + "data": data, + }, + ) + async def async_on_quit(self, sender, data): """Reset the player state on quit action.""" await self._clear_connection() @@ -410,6 +427,7 @@ class KodiEntity(MediaPlayerEntity): dev_reg = dr.async_get(self.hass) device = dev_reg.async_get_device({(DOMAIN, self.unique_id)}) dev_reg.async_update_device(device.id, sw_version=sw_version) + self._device_id = device.id self.async_schedule_update_ha_state(True) @@ -457,6 +475,7 @@ class KodiEntity(MediaPlayerEntity): self._connection.server.Application.OnVolumeChanged = ( self.async_on_volume_changed ) + self._connection.server.Other.OnKeyPress = self.async_on_key_press self._connection.server.System.OnQuit = self.async_on_quit self._connection.server.System.OnRestart = self.async_on_quit self._connection.server.System.OnSleep = self.async_on_quit From e2daffc117478df18f2887cf4dfb7bd39cc0f9ab Mon Sep 17 00:00:00 2001 From: Dara Adib Date: Thu, 25 May 2023 05:29:13 -0400 Subject: [PATCH 564/822] Fix exception handling in Microsoft TTS (#92556) pycsspeechtts uses the requests library, but Microsoft TTS previously caught HTTPException from the standard library. This is changed to catch requests.HTTPError and return `(None, None)` consistent with other TTS integrations. This will properly raise HomeAssistantError for display in the frontend. Follow up to PR #92215 which adds tests for Microsoft TTS. --- homeassistant/components/microsoft/tts.py | 4 ++-- tests/components/microsoft/test_tts.py | 6 ++---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/microsoft/tts.py b/homeassistant/components/microsoft/tts.py index 38f46d886ded..9bcd7f533f8f 100644 --- a/homeassistant/components/microsoft/tts.py +++ b/homeassistant/components/microsoft/tts.py @@ -1,8 +1,8 @@ """Support for the Microsoft Cognitive Services text-to-speech service.""" -from http.client import HTTPException import logging from pycsspeechtts import pycsspeechtts +from requests.exceptions import HTTPError import voluptuous as vol from homeassistant.components.tts import CONF_LANG, PLATFORM_SCHEMA, Provider @@ -121,7 +121,7 @@ class MicrosoftProvider(Provider): contour=self._contour, text=message, ) - except HTTPException as ex: + except HTTPError as ex: _LOGGER.error("Error occurred for Microsoft TTS: %s", ex) return (None, None) return ("mp3", data) diff --git a/tests/components/microsoft/test_tts.py b/tests/components/microsoft/test_tts.py index f01eff556906..dd183ae7120e 100644 --- a/tests/components/microsoft/test_tts.py +++ b/tests/components/microsoft/test_tts.py @@ -3,7 +3,6 @@ from unittest.mock import patch from pycsspeechtts import pycsspeechtts import pytest -from requests import HTTPError from homeassistant.components import media_source, tts from homeassistant.components.media_player import ( @@ -14,7 +13,7 @@ from homeassistant.components.media_player import ( from homeassistant.components.microsoft.tts import SUPPORTED_LANGUAGES from homeassistant.config import async_process_ha_core_config from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ServiceNotFound +from homeassistant.exceptions import HomeAssistantError, ServiceNotFound from homeassistant.setup import async_setup_component from tests.common import async_mock_service @@ -314,7 +313,6 @@ async def test_service_say_error(hass: HomeAssistant, mock_tts, calls) -> None: ) assert len(calls) == 1 - # Note: the integration currently catches HTTPException instead of HTTPError. - with pytest.raises(HTTPError): + with pytest.raises(HomeAssistantError): await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) assert len(mock_tts.mock_calls) == 2 From 3fc0c9a3257d06d373960dec3a906ed70773a807 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 25 May 2023 11:59:20 +0200 Subject: [PATCH 565/822] Improve TTS test fixtures (#93517) --- tests/components/conftest.py | 59 ++++++++++++++++++++++++----- tests/components/tts/common.py | 60 ++++++++++++++++++++++++++++++ tests/components/tts/conftest.py | 64 -------------------------------- 3 files changed, 110 insertions(+), 73 deletions(-) diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 576b3b3b0bc1..ff58909126a9 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -1,17 +1,10 @@ """Fixtures for component testing.""" from collections.abc import Generator -from unittest.mock import patch +from typing import Any +from unittest.mock import MagicMock, patch import pytest -from tests.components.tts.conftest import ( # noqa: F401, pylint: disable=unused-import - init_tts_cache_dir_side_effect_fixture, - mock_tts_cache_dir_fixture, - mock_tts_get_cache_files_fixture, - mock_tts_init_cache_dir_fixture, - tts_mutagen_mock_fixture, -) - @pytest.fixture(scope="session", autouse=True) def patch_zeroconf_multiple_catcher() -> Generator[None, None, None]: @@ -40,3 +33,51 @@ def entity_registry_enabled_by_default() -> Generator[None, None, None]: return_value=True, ): yield + + +@pytest.fixture(name="mock_tts_get_cache_files") +def mock_tts_get_cache_files_fixture(): + """Mock the list TTS cache function.""" + from tests.components.tts.common import mock_tts_get_cache_files_fixture_helper + + yield from mock_tts_get_cache_files_fixture_helper() + + +@pytest.fixture(name="mock_tts_init_cache_dir") +def mock_tts_init_cache_dir_fixture( + init_tts_cache_dir_side_effect: Any, +) -> Generator[MagicMock, None, None]: + """Mock the TTS cache dir in memory.""" + from tests.components.tts.common import mock_tts_init_cache_dir_fixture_helper + + yield from mock_tts_init_cache_dir_fixture_helper(init_tts_cache_dir_side_effect) + + +@pytest.fixture(name="init_tts_cache_dir_side_effect") +def init_tts_cache_dir_side_effect_fixture() -> Any: + """Return the cache dir.""" + from tests.components.tts.common import ( + init_tts_cache_dir_side_effect_fixture_helper, + ) + + return init_tts_cache_dir_side_effect_fixture_helper() + + +@pytest.fixture(name="mock_tts_cache_dir") +def mock_tts_cache_dir_fixture( + tmp_path, mock_tts_init_cache_dir, mock_tts_get_cache_files, request +): + """Mock the TTS cache dir with empty dir.""" + from tests.components.tts.common import mock_tts_cache_dir_fixture_helper + + yield from mock_tts_cache_dir_fixture_helper( + tmp_path, mock_tts_init_cache_dir, mock_tts_get_cache_files, request + ) + + +@pytest.fixture(name="tts_mutagen_mock") +def tts_mutagen_mock_fixture(): + """Mock writing tags.""" + from tests.components.tts.common import tts_mutagen_mock_fixture_helper + + yield from tts_mutagen_mock_fixture_helper() diff --git a/tests/components/tts/common.py b/tests/components/tts/common.py index 6f8de1364846..4e57b85ba4f4 100644 --- a/tests/components/tts/common.py +++ b/tests/components/tts/common.py @@ -1,8 +1,11 @@ """Provide common tests tools for tts.""" from __future__ import annotations +from collections.abc import Generator from typing import Any +from unittest.mock import MagicMock, patch +import pytest import voluptuous as vol from homeassistant.components import media_source @@ -14,6 +17,7 @@ from homeassistant.components.tts import ( TextToSpeechEntity, TtsAudioType, Voice, + _get_cache_files, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback @@ -34,6 +38,62 @@ SUPPORT_LANGUAGES = ["de_CH", "de_DE", "en_GB", "en_US"] TEST_DOMAIN = "test" +def mock_tts_get_cache_files_fixture_helper(): + """Mock the list TTS cache function.""" + with patch( + "homeassistant.components.tts._get_cache_files", return_value={} + ) as mock_cache_files: + yield mock_cache_files + + +def mock_tts_init_cache_dir_fixture_helper( + init_tts_cache_dir_side_effect: Any, +) -> Generator[MagicMock, None, None]: + """Mock the TTS cache dir in memory.""" + with patch( + "homeassistant.components.tts._init_tts_cache_dir", + side_effect=init_tts_cache_dir_side_effect, + ) as mock_cache_dir: + yield mock_cache_dir + + +def init_tts_cache_dir_side_effect_fixture_helper() -> Any: + """Return the cache dir.""" + return None + + +def mock_tts_cache_dir_fixture_helper( + tmp_path, mock_tts_init_cache_dir, mock_tts_get_cache_files, request +): + """Mock the TTS cache dir with empty dir.""" + mock_tts_init_cache_dir.return_value = str(tmp_path) + + # Restore original get cache files behavior, we're working with a real dir. + mock_tts_get_cache_files.side_effect = _get_cache_files + + yield tmp_path + + if not hasattr(request.node, "rep_call") or request.node.rep_call.passed: + return + + # Print contents of dir if failed + print("Content of dir for", request.node.nodeid) # noqa: T201 + for fil in tmp_path.iterdir(): + print(fil.relative_to(tmp_path)) # noqa: T201 + + # To show the log. + pytest.fail("Test failed, see log for details") + + +def tts_mutagen_mock_fixture_helper(): + """Mock writing tags.""" + with patch( + "homeassistant.components.tts.SpeechManager.write_tags", + side_effect=lambda *args: args[1], + ) as mock_write_tags: + yield mock_write_tags + + async def get_media_source_url(hass: HomeAssistant, media_content_id: str) -> str: """Get the media source url.""" if media_source.DOMAIN not in hass.config.components: diff --git a/tests/components/tts/conftest.py b/tests/components/tts/conftest.py index d7ec722bbf20..753c90e158d2 100644 --- a/tests/components/tts/conftest.py +++ b/tests/components/tts/conftest.py @@ -3,12 +3,9 @@ From http://doc.pytest.org/en/latest/example/simple.html#making-test-result-information-available-in-fixtures """ from collections.abc import Generator -from typing import Any -from unittest.mock import MagicMock, patch import pytest -from homeassistant.components.tts import _get_cache_files from homeassistant.config import async_process_ha_core_config from homeassistant.config_entries import ConfigFlow from homeassistant.core import HomeAssistant @@ -38,73 +35,12 @@ def pytest_runtest_makereport(item, call): setattr(item, f"rep_{rep.when}", rep) -@pytest.fixture(name="mock_tts_get_cache_files") -def mock_tts_get_cache_files_fixture(): - """Mock the list TTS cache function.""" - with patch( - "homeassistant.components.tts._get_cache_files", return_value={} - ) as mock_cache_files: - yield mock_cache_files - - -@pytest.fixture(name="mock_tts_init_cache_dir") -def mock_tts_init_cache_dir_fixture( - init_tts_cache_dir_side_effect: Any, -) -> Generator[MagicMock, None, None]: - """Mock the TTS cache dir in memory.""" - with patch( - "homeassistant.components.tts._init_tts_cache_dir", - side_effect=init_tts_cache_dir_side_effect, - ) as mock_cache_dir: - yield mock_cache_dir - - -@pytest.fixture(name="init_tts_cache_dir_side_effect") -def init_tts_cache_dir_side_effect_fixture() -> Any: - """Return the cache dir.""" - return None - - -@pytest.fixture(name="mock_tts_cache_dir") -def mock_tts_cache_dir_fixture( - tmp_path, mock_tts_init_cache_dir, mock_tts_get_cache_files, request -): - """Mock the TTS cache dir with empty dir.""" - mock_tts_init_cache_dir.return_value = str(tmp_path) - - # Restore original get cache files behavior, we're working with a real dir. - mock_tts_get_cache_files.side_effect = _get_cache_files - - yield tmp_path - - if not hasattr(request.node, "rep_call") or request.node.rep_call.passed: - return - - # Print contents of dir if failed - print("Content of dir for", request.node.nodeid) # noqa: T201 - for fil in tmp_path.iterdir(): - print(fil.relative_to(tmp_path)) # noqa: T201 - - # To show the log. - pytest.fail("Test failed, see log for details") - - @pytest.fixture(autouse=True, name="mock_tts_cache_dir") def mock_tts_cache_dir_fixture_autouse(mock_tts_cache_dir): """Mock the TTS cache dir with empty dir.""" return mock_tts_cache_dir -@pytest.fixture(name="tts_mutagen_mock") -def tts_mutagen_mock_fixture(): - """Mock writing tags.""" - with patch( - "homeassistant.components.tts.SpeechManager.write_tags", - side_effect=lambda *args: args[1], - ) as mock_write_tags: - yield mock_write_tags - - @pytest.fixture(autouse=True) def tts_mutagen_mock_fixture_autouse(tts_mutagen_mock): """Mock writing tags.""" From 9231010402cc06b37e3b761c5a6b4f1cbe8ba638 Mon Sep 17 00:00:00 2001 From: IceBotYT <34712694+IceBotYT@users.noreply.github.com> Date: Thu, 25 May 2023 06:37:16 -0400 Subject: [PATCH 566/822] Code quality improvements and debug logs for LaCrosse View (#91979) * Code quality improvements and debug logs for LaCrosse View * Remove unrelated files * Remove version bump * Revert "Remove version bump" This reverts commit ed440899f97fe43932fd40f32e1316c403e1504c. * Revert "Remove unrelated files" This reverts commit d9b48ae4f9935c377c177f3bc048351b612a8cb8. --- .../components/lacrosse_view/__init__.py | 6 ++++++ .../components/lacrosse_view/config_flow.py | 19 ++++++++++++++----- .../components/lacrosse_view/const.py | 2 -- .../components/lacrosse_view/coordinator.py | 10 ++++++++-- .../components/lacrosse_view/sensor.py | 7 +++++-- 5 files changed, 33 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/lacrosse_view/__init__.py b/homeassistant/components/lacrosse_view/__init__.py index 46239485eb31..86793a94a4bd 100644 --- a/homeassistant/components/lacrosse_view/__init__.py +++ b/homeassistant/components/lacrosse_view/__init__.py @@ -1,6 +1,8 @@ """The LaCrosse View integration.""" from __future__ import annotations +import logging + from lacrosse_view import LaCrosse, LoginError from homeassistant.config_entries import ConfigEntry @@ -13,6 +15,7 @@ from .const import DOMAIN from .coordinator import LaCrosseUpdateCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] +_LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -22,17 +25,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: await api.login(entry.data["username"], entry.data["password"]) + _LOGGER.debug("Log in successful") except LoginError as error: raise ConfigEntryAuthFailed from error coordinator = LaCrosseUpdateCoordinator(hass, api, entry) + _LOGGER.debug("First refresh") await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { "coordinator": coordinator, } + _LOGGER.debug("Setting up platforms") await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/lacrosse_view/config_flow.py b/homeassistant/components/lacrosse_view/config_flow.py index 2b694860bc81..67d294de1791 100644 --- a/homeassistant/components/lacrosse_view/config_flow.py +++ b/homeassistant/components/lacrosse_view/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections.abc import Mapping +import logging from typing import Any from lacrosse_view import LaCrosse, Location, LoginError @@ -13,7 +14,7 @@ from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN, LOGGER +from .const import DOMAIN STEP_USER_DATA_SCHEMA = vol.Schema( { @@ -21,6 +22,7 @@ STEP_USER_DATA_SCHEMA = vol.Schema( vol.Required("password"): str, } ) +_LOGGER = logging.getLogger(__name__) async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> list[Location]: @@ -29,14 +31,16 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> list[Loca api = LaCrosse(async_get_clientsession(hass)) try: - await api.login(data["username"], data["password"]) + if await api.login(data["username"], data["password"]): + _LOGGER.debug("Successfully logged in") locations = await api.get_locations() + _LOGGER.debug(locations) except LoginError as error: raise InvalidAuth from error if not locations: - raise NoLocations("No locations found for account {}".format(data["username"])) + raise NoLocations(f'No locations found for account {data["username"]}') return locations @@ -57,6 +61,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) -> FlowResult: """Handle the initial step.""" if user_input is None: + _LOGGER.debug("Showing initial form") return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA ) @@ -66,11 +71,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): try: info = await validate_input(self.hass, user_input) except InvalidAuth: + _LOGGER.exception("Could not login") errors["base"] = "invalid_auth" except NoLocations: errors["base"] = "no_locations" except Exception: # pylint: disable=broad-except - LOGGER.exception("Unexpected exception") + _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: self.data = user_input @@ -83,8 +89,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) await self.hass.config_entries.async_reload(self._reauth_entry.entry_id) return self.async_abort(reason="reauth_successful") + + _LOGGER.debug("Moving on to location step") return await self.async_step_location() + _LOGGER.debug("Showing errors") return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) @@ -95,6 +104,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle the location step.""" if not user_input: + _LOGGER.debug("Showing initial location selection") return self.async_show_form( step_id="location", data_schema=vol.Schema( @@ -113,7 +123,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) await self.async_set_unique_id(location_id) - self._abort_if_unique_id_configured() return self.async_create_entry( diff --git a/homeassistant/components/lacrosse_view/const.py b/homeassistant/components/lacrosse_view/const.py index cae11315bc7a..900463cff6e3 100644 --- a/homeassistant/components/lacrosse_view/const.py +++ b/homeassistant/components/lacrosse_view/const.py @@ -1,6 +1,4 @@ """Constants for the LaCrosse View integration.""" -import logging DOMAIN = "lacrosse_view" -LOGGER = logging.getLogger(__package__) SCAN_INTERVAL = 30 diff --git a/homeassistant/components/lacrosse_view/coordinator.py b/homeassistant/components/lacrosse_view/coordinator.py index 8dcbd8a2e5ea..b45fe3ae1b4e 100644 --- a/homeassistant/components/lacrosse_view/coordinator.py +++ b/homeassistant/components/lacrosse_view/coordinator.py @@ -2,6 +2,7 @@ from __future__ import annotations from datetime import timedelta +import logging from time import time from lacrosse_view import HTTPError, LaCrosse, Location, LoginError, Sensor @@ -11,7 +12,9 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import LOGGER, SCAN_INTERVAL +from .const import SCAN_INTERVAL + +_LOGGER = logging.getLogger(__name__) class LaCrosseUpdateCoordinator(DataUpdateCoordinator[list[Sensor]]): @@ -39,7 +42,7 @@ class LaCrosseUpdateCoordinator(DataUpdateCoordinator[list[Sensor]]): self.id = entry.data["id"] super().__init__( hass, - LOGGER, + _LOGGER, name="LaCrosse View", update_interval=timedelta(seconds=SCAN_INTERVAL), ) @@ -49,6 +52,7 @@ class LaCrosseUpdateCoordinator(DataUpdateCoordinator[list[Sensor]]): now = int(time()) if self.last_update < now - 59 * 60: # Get new token once in a hour + _LOGGER.debug("Refreshing token") self.last_update = now try: await self.api.login(self.username, self.password) @@ -66,6 +70,8 @@ class LaCrosseUpdateCoordinator(DataUpdateCoordinator[list[Sensor]]): except HTTPError as error: raise ConfigEntryNotReady from error + _LOGGER.debug("Got data: %s", sensors) + # Verify that we have permission to read the sensors for sensor in sensors: if not sensor.permissions.get("read", False): diff --git a/homeassistant/components/lacrosse_view/sensor.py b/homeassistant/components/lacrosse_view/sensor.py index 1c2daa2ba4ab..e001450fab05 100644 --- a/homeassistant/components/lacrosse_view/sensor.py +++ b/homeassistant/components/lacrosse_view/sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +import logging from lacrosse_view import Sensor @@ -28,7 +29,9 @@ from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator, ) -from .const import DOMAIN, LOGGER +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) @dataclass @@ -169,7 +172,7 @@ async def async_setup_entry( f"title=LaCrosse%20View%20Unsupported%20sensor%20field:%20{field}" ) - LOGGER.warning(message) + _LOGGER.warning(message) continue sensor_list.append( LaCrosseViewSensor( From 393dbb6dc0469ce3d42ee32b07134817089e426f Mon Sep 17 00:00:00 2001 From: badewanne1234 <82471417+badewanne1234@users.noreply.github.com> Date: Thu, 25 May 2023 12:41:42 +0200 Subject: [PATCH 567/822] Fix of cover-tilt in homekit_controller (#91631) * Fix of cover-tilt in homekit_controller * Fix of cover-tilt and its tests --- .../components/homekit_controller/cover.py | 39 ++++++ .../homekit_controller/test_cover.py | 124 +++++++++++++++++- 2 files changed, 159 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/homekit_controller/cover.py b/homeassistant/components/homekit_controller/cover.py index fbe6f08bc75f..73eb699007c7 100644 --- a/homeassistant/components/homekit_controller/cover.py +++ b/homeassistant/components/homekit_controller/cover.py @@ -216,6 +216,26 @@ class HomeKitWindowCover(HomeKitEntity, CoverEntity): tilt_position = self.service.value( CharacteristicsTypes.HORIZONTAL_TILT_CURRENT ) + # Recalculate to convert from arcdegree scale to percentage scale. + if self.is_vertical_tilt: + scale = 0.9 + if ( + self.service[CharacteristicsTypes.VERTICAL_TILT_CURRENT].minValue == -90 + and self.service[CharacteristicsTypes.VERTICAL_TILT_CURRENT].maxValue + == 0 + ): + scale = -0.9 + tilt_position = int(tilt_position / scale) + elif self.is_horizontal_tilt: + scale = 0.9 + if ( + self.service[CharacteristicsTypes.HORIZONTAL_TILT_TARGET].minValue + == -90 + and self.service[CharacteristicsTypes.HORIZONTAL_TILT_TARGET].maxValue + == 0 + ): + scale = -0.9 + tilt_position = int(tilt_position / scale) return tilt_position async def async_stop_cover(self, **kwargs: Any) -> None: @@ -241,10 +261,29 @@ class HomeKitWindowCover(HomeKitEntity, CoverEntity): """Move the cover tilt to a specific position.""" tilt_position = kwargs[ATTR_TILT_POSITION] if self.is_vertical_tilt: + # Recalculate to convert from percentage scale to arcdegree scale. + scale = 0.9 + if ( + self.service[CharacteristicsTypes.VERTICAL_TILT_TARGET].minValue == -90 + and self.service[CharacteristicsTypes.VERTICAL_TILT_TARGET].maxValue + == 0 + ): + scale = -0.9 + tilt_position = int(tilt_position * scale) await self.async_put_characteristics( {CharacteristicsTypes.VERTICAL_TILT_TARGET: tilt_position} ) elif self.is_horizontal_tilt: + # Recalculate to convert from percentage scale to arcdegree scale. + scale = 0.9 + if ( + self.service[CharacteristicsTypes.HORIZONTAL_TILT_TARGET].minValue + == -90 + and self.service[CharacteristicsTypes.HORIZONTAL_TILT_TARGET].maxValue + == 0 + ): + scale = -0.9 + tilt_position = int(tilt_position * scale) await self.async_put_characteristics( {CharacteristicsTypes.HORIZONTAL_TILT_TARGET: tilt_position} ) diff --git a/tests/components/homekit_controller/test_cover.py b/tests/components/homekit_controller/test_cover.py index aa59c2af3ed6..5a389311daaf 100644 --- a/tests/components/homekit_controller/test_cover.py +++ b/tests/components/homekit_controller/test_cover.py @@ -39,9 +39,28 @@ def create_window_covering_service_with_h_tilt(accessory): tilt_current = service.add_char(CharacteristicsTypes.HORIZONTAL_TILT_CURRENT) tilt_current.value = 0 + tilt_current.minValue = 0 + tilt_current.maxValue = 90 tilt_target = service.add_char(CharacteristicsTypes.HORIZONTAL_TILT_TARGET) tilt_target.value = 0 + tilt_target.minValue = 0 + tilt_target.maxValue = 90 + + +def create_window_covering_service_with_h_tilt_2(accessory): + """Define a window-covering characteristics as per page 219 of HAP spec.""" + service = create_window_covering_service(accessory) + + tilt_current = service.add_char(CharacteristicsTypes.HORIZONTAL_TILT_CURRENT) + tilt_current.value = 0 + tilt_current.minValue = -90 + tilt_current.maxValue = 0 + + tilt_target = service.add_char(CharacteristicsTypes.HORIZONTAL_TILT_TARGET) + tilt_target.value = 0 + tilt_target.minValue = -90 + tilt_target.maxValue = 0 def create_window_covering_service_with_v_tilt(accessory): @@ -50,9 +69,28 @@ def create_window_covering_service_with_v_tilt(accessory): tilt_current = service.add_char(CharacteristicsTypes.VERTICAL_TILT_CURRENT) tilt_current.value = 0 + tilt_current.minValue = 0 + tilt_current.maxValue = 90 tilt_target = service.add_char(CharacteristicsTypes.VERTICAL_TILT_TARGET) tilt_target.value = 0 + tilt_target.minValue = 0 + tilt_target.maxValue = 90 + + +def create_window_covering_service_with_v_tilt_2(accessory): + """Define a window-covering characteristics as per page 219 of HAP spec.""" + service = create_window_covering_service(accessory) + + tilt_current = service.add_char(CharacteristicsTypes.VERTICAL_TILT_CURRENT) + tilt_current.value = 0 + tilt_current.minValue = -90 + tilt_current.maxValue = 0 + + tilt_target = service.add_char(CharacteristicsTypes.VERTICAL_TILT_TARGET) + tilt_target.value = 0 + tilt_target.minValue = -90 + tilt_target.maxValue = 0 async def test_change_window_cover_state(hass: HomeAssistant, utcnow) -> None: @@ -124,7 +162,23 @@ async def test_read_window_cover_tilt_horizontal(hass: HomeAssistant, utcnow) -> {CharacteristicsTypes.HORIZONTAL_TILT_CURRENT: 75}, ) state = await helper.poll_and_get_state() - assert state.attributes["current_tilt_position"] == 75 + # Expect converted value from arcdegree scale to percentage scale. + assert state.attributes["current_tilt_position"] == 83 + + +async def test_read_window_cover_tilt_horizontal_2(hass: HomeAssistant, utcnow) -> None: + """Test that horizontal tilt is handled correctly.""" + helper = await setup_test_component( + hass, create_window_covering_service_with_h_tilt_2 + ) + + await helper.async_update( + ServicesTypes.WINDOW_COVERING, + {CharacteristicsTypes.HORIZONTAL_TILT_CURRENT: -75}, + ) + state = await helper.poll_and_get_state() + # Expect converted value from arcdegree scale to percentage scale. + assert state.attributes["current_tilt_position"] == 83 async def test_read_window_cover_tilt_vertical(hass: HomeAssistant, utcnow) -> None: @@ -138,7 +192,23 @@ async def test_read_window_cover_tilt_vertical(hass: HomeAssistant, utcnow) -> N {CharacteristicsTypes.VERTICAL_TILT_CURRENT: 75}, ) state = await helper.poll_and_get_state() - assert state.attributes["current_tilt_position"] == 75 + # Expect converted value from arcdegree scale to percentage scale. + assert state.attributes["current_tilt_position"] == 83 + + +async def test_read_window_cover_tilt_vertical_2(hass: HomeAssistant, utcnow) -> None: + """Test that vertical tilt is handled correctly.""" + helper = await setup_test_component( + hass, create_window_covering_service_with_v_tilt_2 + ) + + await helper.async_update( + ServicesTypes.WINDOW_COVERING, + {CharacteristicsTypes.VERTICAL_TILT_CURRENT: -75}, + ) + state = await helper.poll_and_get_state() + # Expect converted value from arcdegree scale to percentage scale. + assert state.attributes["current_tilt_position"] == 83 async def test_write_window_cover_tilt_horizontal(hass: HomeAssistant, utcnow) -> None: @@ -153,10 +223,34 @@ async def test_write_window_cover_tilt_horizontal(hass: HomeAssistant, utcnow) - {"entity_id": helper.entity_id, "tilt_position": 90}, blocking=True, ) + # Expect converted value from percentage scale to arcdegree scale. helper.async_assert_service_values( ServicesTypes.WINDOW_COVERING, { - CharacteristicsTypes.HORIZONTAL_TILT_TARGET: 90, + CharacteristicsTypes.HORIZONTAL_TILT_TARGET: 81, + }, + ) + + +async def test_write_window_cover_tilt_horizontal_2( + hass: HomeAssistant, utcnow +) -> None: + """Test that horizontal tilt is written correctly.""" + helper = await setup_test_component( + hass, create_window_covering_service_with_h_tilt_2 + ) + + await hass.services.async_call( + "cover", + "set_cover_tilt_position", + {"entity_id": helper.entity_id, "tilt_position": 90}, + blocking=True, + ) + # Expect converted value from percentage scale to arcdegree scale. + helper.async_assert_service_values( + ServicesTypes.WINDOW_COVERING, + { + CharacteristicsTypes.HORIZONTAL_TILT_TARGET: -81, }, ) @@ -173,10 +267,32 @@ async def test_write_window_cover_tilt_vertical(hass: HomeAssistant, utcnow) -> {"entity_id": helper.entity_id, "tilt_position": 90}, blocking=True, ) + # Expect converted value from percentage scale to arcdegree scale. helper.async_assert_service_values( ServicesTypes.WINDOW_COVERING, { - CharacteristicsTypes.VERTICAL_TILT_TARGET: 90, + CharacteristicsTypes.VERTICAL_TILT_TARGET: 81, + }, + ) + + +async def test_write_window_cover_tilt_vertical_2(hass: HomeAssistant, utcnow) -> None: + """Test that vertical tilt is written correctly.""" + helper = await setup_test_component( + hass, create_window_covering_service_with_v_tilt_2 + ) + + await hass.services.async_call( + "cover", + "set_cover_tilt_position", + {"entity_id": helper.entity_id, "tilt_position": 90}, + blocking=True, + ) + # Expect converted value from percentage scale to arcdegree scale. + helper.async_assert_service_values( + ServicesTypes.WINDOW_COVERING, + { + CharacteristicsTypes.VERTICAL_TILT_TARGET: -81, }, ) From 3cca338c6e0419e10d6e144a2d20474f55af8ba8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 25 May 2023 13:45:19 +0200 Subject: [PATCH 568/822] Improve blueprint test fixtures (#93519) --- .../alarm_control_panel/test_device_action.py | 6 +++++- .../alarm_control_panel/test_device_condition.py | 6 +++++- .../alarm_control_panel/test_device_trigger.py | 6 +++++- tests/components/arcam_fmj/test_device_trigger.py | 6 +++++- tests/components/automation/conftest.py | 7 ++++++- .../components/binary_sensor/test_device_condition.py | 6 +++++- tests/components/binary_sensor/test_device_trigger.py | 6 +++++- tests/components/blueprint/common.py | 11 +++++++++++ tests/components/blueprint/conftest.py | 10 ++-------- tests/components/climate/test_device_action.py | 6 +++++- tests/components/climate/test_device_condition.py | 6 +++++- tests/components/climate/test_device_trigger.py | 6 +++++- tests/components/config/test_automation.py | 6 +++++- tests/components/config/test_device_registry.py | 6 +++++- tests/components/config/test_script.py | 6 +++++- tests/components/conftest.py | 10 ++++++++++ tests/components/cover/test_device_action.py | 6 +++++- tests/components/cover/test_device_condition.py | 6 +++++- tests/components/cover/test_device_trigger.py | 6 +++++- tests/components/deconz/test_device_trigger.py | 6 +++++- tests/components/default_config/test_init.py | 5 ++++- tests/components/demo/conftest.py | 6 +++++- tests/components/device_automation/test_init.py | 6 +++++- .../device_automation/test_toggle_entity.py | 6 +++++- .../device_tracker/test_device_condition.py | 6 +++++- .../components/device_tracker/test_device_trigger.py | 7 ++++++- tests/components/emulated_hue/conftest.py | 7 ++++++- tests/components/fan/test_device_action.py | 6 +++++- tests/components/fan/test_device_condition.py | 6 +++++- tests/components/fan/test_device_trigger.py | 6 +++++- tests/components/geo_location/test_trigger.py | 6 +++++- tests/components/homeassistant/triggers/conftest.py | 7 ++++++- .../homekit_controller/test_device_trigger.py | 6 +++++- tests/components/humidifier/test_device_action.py | 6 +++++- tests/components/humidifier/test_device_condition.py | 6 +++++- tests/components/humidifier/test_device_trigger.py | 6 +++++- tests/components/kodi/test_device_trigger.py | 6 +++++- tests/components/light/test_device_action.py | 6 +++++- tests/components/light/test_device_condition.py | 6 +++++- tests/components/light/test_device_trigger.py | 6 +++++- tests/components/litejet/test_trigger.py | 7 ++++++- tests/components/lock/test_device_action.py | 6 +++++- tests/components/lock/test_device_condition.py | 6 +++++- tests/components/lock/test_device_trigger.py | 6 +++++- tests/components/logbook/conftest.py | 7 ++++++- .../components/media_player/test_device_condition.py | 6 +++++- tests/components/media_player/test_device_trigger.py | 6 +++++- tests/components/mqtt/conftest.py | 6 +++++- tests/components/mqtt/test_device_trigger.py | 6 +++++- tests/components/mqtt/test_trigger.py | 6 +++++- tests/components/number/test_device_action.py | 6 +++++- tests/components/philips_js/test_device_trigger.py | 6 +++++- tests/components/remote/test_device_action.py | 6 +++++- tests/components/remote/test_device_condition.py | 6 +++++- tests/components/remote/test_device_trigger.py | 6 +++++- tests/components/script/conftest.py | 7 ++++++- tests/components/search/test_init.py | 9 ++++++++- tests/components/sensor/test_device_condition.py | 6 +++++- tests/components/sensor/test_device_trigger.py | 6 +++++- tests/components/sun/test_trigger.py | 6 +++++- tests/components/switch/test_device_action.py | 6 +++++- tests/components/switch/test_device_condition.py | 6 +++++- tests/components/switch/test_device_trigger.py | 6 +++++- tests/components/tag/test_trigger.py | 6 +++++- tests/components/tasmota/test_device_trigger.py | 6 +++++- tests/components/text/test_device_action.py | 6 +++++- tests/components/trace/conftest.py | 7 ++++++- tests/components/update/test_device_trigger.py | 6 +++++- tests/components/vacuum/test_device_action.py | 6 +++++- tests/components/vacuum/test_device_condition.py | 6 +++++- tests/components/vacuum/test_device_trigger.py | 6 +++++- tests/components/water_heater/test_device_action.py | 6 +++++- tests/components/webhook/test_trigger.py | 6 +++++- tests/components/zha/test_device_action.py | 7 ++++++- tests/components/zha/test_device_trigger.py | 7 ++++++- tests/components/zone/test_trigger.py | 6 +++++- 76 files changed, 400 insertions(+), 81 deletions(-) create mode 100644 tests/components/blueprint/common.py diff --git a/tests/components/alarm_control_panel/test_device_action.py b/tests/components/alarm_control_panel/test_device_action.py index 0c6731c92be7..5c9f6622905d 100644 --- a/tests/components/alarm_control_panel/test_device_action.py +++ b/tests/components/alarm_control_panel/test_device_action.py @@ -28,7 +28,11 @@ from tests.common import ( async_get_device_automation_capabilities, async_get_device_automations, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" @pytest.mark.parametrize( diff --git a/tests/components/alarm_control_panel/test_device_condition.py b/tests/components/alarm_control_panel/test_device_condition.py index 7a3ca14e0cc3..d56ddccab64f 100644 --- a/tests/components/alarm_control_panel/test_device_condition.py +++ b/tests/components/alarm_control_panel/test_device_condition.py @@ -27,7 +27,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/alarm_control_panel/test_device_trigger.py b/tests/components/alarm_control_panel/test_device_trigger.py index f6eeef952bdd..0cdcf1d88506 100644 --- a/tests/components/alarm_control_panel/test_device_trigger.py +++ b/tests/components/alarm_control_panel/test_device_trigger.py @@ -32,7 +32,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/arcam_fmj/test_device_trigger.py b/tests/components/arcam_fmj/test_device_trigger.py index 64eef53c31ad..48561f251b37 100644 --- a/tests/components/arcam_fmj/test_device_trigger.py +++ b/tests/components/arcam_fmj/test_device_trigger.py @@ -13,7 +13,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/automation/conftest.py b/tests/components/automation/conftest.py index 438948e84754..1aa56bbf9b94 100644 --- a/tests/components/automation/conftest.py +++ b/tests/components/automation/conftest.py @@ -1,3 +1,8 @@ """Conftest for automation tests.""" -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 +import pytest + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/binary_sensor/test_device_condition.py b/tests/components/binary_sensor/test_device_condition.py index ecc5f7bc8b03..2e1f77aa091e 100644 --- a/tests/components/binary_sensor/test_device_condition.py +++ b/tests/components/binary_sensor/test_device_condition.py @@ -21,7 +21,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/binary_sensor/test_device_trigger.py b/tests/components/binary_sensor/test_device_trigger.py index 1866b155da38..7f2cf7a5ba57 100644 --- a/tests/components/binary_sensor/test_device_trigger.py +++ b/tests/components/binary_sensor/test_device_trigger.py @@ -21,7 +21,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/blueprint/common.py b/tests/components/blueprint/common.py new file mode 100644 index 000000000000..28dad51d4f47 --- /dev/null +++ b/tests/components/blueprint/common.py @@ -0,0 +1,11 @@ +"""Blueprints test helpers.""" + +from unittest.mock import patch + + +def stub_blueprint_populate_fixture_helper(): + """Stub copying the blueprints to the config folder.""" + with patch( + "homeassistant.components.blueprint.models.DomainBlueprints.async_populate" + ): + yield diff --git a/tests/components/blueprint/conftest.py b/tests/components/blueprint/conftest.py index fe0df5d82607..2f54fb6092e3 100644 --- a/tests/components/blueprint/conftest.py +++ b/tests/components/blueprint/conftest.py @@ -1,14 +1,8 @@ """Blueprints conftest.""" -from unittest.mock import patch - import pytest -@pytest.fixture(autouse=True) -def stub_blueprint_populate(): +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): """Stub copying the blueprints to the config folder.""" - with patch( - "homeassistant.components.blueprint.models.DomainBlueprints.async_populate" - ): - yield diff --git a/tests/components/climate/test_device_action.py b/tests/components/climate/test_device_action.py index cec7722ddc81..afbbccf70ecc 100644 --- a/tests/components/climate/test_device_action.py +++ b/tests/components/climate/test_device_action.py @@ -21,7 +21,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" @pytest.mark.parametrize( diff --git a/tests/components/climate/test_device_condition.py b/tests/components/climate/test_device_condition.py index 44ce904bf9ea..7d90e92101c7 100644 --- a/tests/components/climate/test_device_condition.py +++ b/tests/components/climate/test_device_condition.py @@ -21,7 +21,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/climate/test_device_trigger.py b/tests/components/climate/test_device_trigger.py index 54f89290a45b..14d21b41e019 100644 --- a/tests/components/climate/test_device_trigger.py +++ b/tests/components/climate/test_device_trigger.py @@ -27,7 +27,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/config/test_automation.py b/tests/components/config/test_automation.py index dcc76d9df0c2..5122c7ec7497 100644 --- a/tests/components/config/test_automation.py +++ b/tests/components/config/test_automation.py @@ -10,10 +10,14 @@ from homeassistant.components import config from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 from tests.typing import ClientSessionGenerator +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" + + @pytest.fixture async def setup_automation( hass, automation_config, stub_blueprint_populate # noqa: F811 diff --git a/tests/components/config/test_device_registry.py b/tests/components/config/test_device_registry.py index fef7bad98980..aa08a705f46d 100644 --- a/tests/components/config/test_device_registry.py +++ b/tests/components/config/test_device_registry.py @@ -7,10 +7,14 @@ from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, MockModule, mock_integration -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 from tests.typing import WebSocketGenerator +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" + + @pytest.fixture def client(hass, hass_ws_client): """Fixture that can interact with the config manager API.""" diff --git a/tests/components/config/test_script.py b/tests/components/config/test_script.py index c151157b937e..934d7ef7b895 100644 --- a/tests/components/config/test_script.py +++ b/tests/components/config/test_script.py @@ -10,10 +10,14 @@ from homeassistant.components import config from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 from tests.typing import ClientSessionGenerator +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" + + @pytest.fixture(autouse=True) async def setup_script(hass, script_config, stub_blueprint_populate): # noqa: F811 """Set up script integration.""" diff --git a/tests/components/conftest.py b/tests/components/conftest.py index ff58909126a9..1deb857c3156 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -35,6 +35,16 @@ def entity_registry_enabled_by_default() -> Generator[None, None, None]: yield +# Blueprint test fixtures +@pytest.fixture(name="stub_blueprint_populate") +def stub_blueprint_populate_fixture(): + """Stub copying the blueprints to the config folder.""" + from tests.components.blueprint.common import stub_blueprint_populate_fixture_helper + + yield from stub_blueprint_populate_fixture_helper() + + +# TTS test fixtures @pytest.fixture(name="mock_tts_get_cache_files") def mock_tts_get_cache_files_fixture(): """Mock the list TTS cache function.""" diff --git a/tests/components/cover/test_device_action.py b/tests/components/cover/test_device_action.py index 354e840e5486..a67c84a50596 100644 --- a/tests/components/cover/test_device_action.py +++ b/tests/components/cover/test_device_action.py @@ -17,7 +17,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" @pytest.mark.parametrize( diff --git a/tests/components/cover/test_device_condition.py b/tests/components/cover/test_device_condition.py index d8f9b9547fd4..1eb3aa22a06b 100644 --- a/tests/components/cover/test_device_condition.py +++ b/tests/components/cover/test_device_condition.py @@ -25,7 +25,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/cover/test_device_trigger.py b/tests/components/cover/test_device_trigger.py index a9d5d2b6ee1c..ef391707a9b2 100644 --- a/tests/components/cover/test_device_trigger.py +++ b/tests/components/cover/test_device_trigger.py @@ -28,7 +28,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/deconz/test_device_trigger.py b/tests/components/deconz/test_device_trigger.py index d574d6d96470..2d87358ebb0b 100644 --- a/tests/components/deconz/test_device_trigger.py +++ b/tests/components/deconz/test_device_trigger.py @@ -36,10 +36,14 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 from tests.test_util.aiohttp import AiohttpClientMocker +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" + + @pytest.fixture def automation_calls(hass): """Track automation calls to a mock service.""" diff --git a/tests/components/default_config/test_init.py b/tests/components/default_config/test_init.py index 1f7c08202020..846cb3c40f62 100644 --- a/tests/components/default_config/test_init.py +++ b/tests/components/default_config/test_init.py @@ -7,7 +7,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import recorder as recorder_helper from homeassistant.setup import async_setup_component -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" @pytest.fixture(autouse=True) diff --git a/tests/components/demo/conftest.py b/tests/components/demo/conftest.py index a6182289a866..6edcf4959183 100644 --- a/tests/components/demo/conftest.py +++ b/tests/components/demo/conftest.py @@ -4,10 +4,14 @@ import pytest from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 from tests.components.light.conftest import mock_light_profiles # noqa: F401 +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" + + @pytest.fixture(autouse=True) async def setup_homeassistant(hass: HomeAssistant): """Set up the homeassistant integration.""" diff --git a/tests/components/device_automation/test_init.py b/tests/components/device_automation/test_init.py index 2fdea0a0bb1d..8b082f26de4e 100644 --- a/tests/components/device_automation/test_init.py +++ b/tests/components/device_automation/test_init.py @@ -28,10 +28,14 @@ from tests.common import ( mock_integration, mock_platform, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 from tests.typing import WebSocketGenerator +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" + + @pytest.fixture def fake_integration(hass): """Set up a mock integration with device automation support.""" diff --git a/tests/components/device_automation/test_toggle_entity.py b/tests/components/device_automation/test_toggle_entity.py index 6d89a59ee946..8f12a3fc5d20 100644 --- a/tests/components/device_automation/test_toggle_entity.py +++ b/tests/components/device_automation/test_toggle_entity.py @@ -10,7 +10,11 @@ from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from tests.common import async_fire_time_changed, async_mock_service -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/device_tracker/test_device_condition.py b/tests/components/device_tracker/test_device_condition.py index 1ac6dfdeed90..2089722389d4 100644 --- a/tests/components/device_tracker/test_device_condition.py +++ b/tests/components/device_tracker/test_device_condition.py @@ -16,7 +16,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/device_tracker/test_device_trigger.py b/tests/components/device_tracker/test_device_trigger.py index c99e2d4928e4..517295774bf7 100644 --- a/tests/components/device_tracker/test_device_trigger.py +++ b/tests/components/device_tracker/test_device_trigger.py @@ -22,7 +22,12 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" + AWAY_LATITUDE = 32.881011 AWAY_LONGITUDE = -117.234758 diff --git a/tests/components/emulated_hue/conftest.py b/tests/components/emulated_hue/conftest.py index 8b47cca6d5d6..652ca6536e30 100644 --- a/tests/components/emulated_hue/conftest.py +++ b/tests/components/emulated_hue/conftest.py @@ -1,3 +1,8 @@ """Conftest for emulated_hue tests.""" -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 +import pytest + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/fan/test_device_action.py b/tests/components/fan/test_device_action.py index fef3c4cc0e71..195eb932d7a5 100644 --- a/tests/components/fan/test_device_action.py +++ b/tests/components/fan/test_device_action.py @@ -16,7 +16,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" async def test_get_actions( diff --git a/tests/components/fan/test_device_condition.py b/tests/components/fan/test_device_condition.py index 5f34850cd6ab..516cdea1e6a0 100644 --- a/tests/components/fan/test_device_condition.py +++ b/tests/components/fan/test_device_condition.py @@ -16,7 +16,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/fan/test_device_trigger.py b/tests/components/fan/test_device_trigger.py index 9f32a79bd314..cb2b557dcadf 100644 --- a/tests/components/fan/test_device_trigger.py +++ b/tests/components/fan/test_device_trigger.py @@ -21,7 +21,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/geo_location/test_trigger.py b/tests/components/geo_location/test_trigger.py index ea5c051d4d23..8fcbc9d2f55c 100644 --- a/tests/components/geo_location/test_trigger.py +++ b/tests/components/geo_location/test_trigger.py @@ -14,7 +14,11 @@ from homeassistant.core import Context, HomeAssistant from homeassistant.setup import async_setup_component from tests.common import async_mock_service, mock_component -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/homeassistant/triggers/conftest.py b/tests/components/homeassistant/triggers/conftest.py index 77520a1bf689..9f53537819b7 100644 --- a/tests/components/homeassistant/triggers/conftest.py +++ b/tests/components/homeassistant/triggers/conftest.py @@ -1,3 +1,8 @@ """Conftest for HA triggers.""" -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 +import pytest + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/homekit_controller/test_device_trigger.py b/tests/components/homekit_controller/test_device_trigger.py index 015d567ece7f..580f46fb821e 100644 --- a/tests/components/homekit_controller/test_device_trigger.py +++ b/tests/components/homekit_controller/test_device_trigger.py @@ -18,7 +18,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/humidifier/test_device_action.py b/tests/components/humidifier/test_device_action.py index 219571918367..1bcb09d45837 100644 --- a/tests/components/humidifier/test_device_action.py +++ b/tests/components/humidifier/test_device_action.py @@ -21,7 +21,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" @pytest.mark.parametrize( diff --git a/tests/components/humidifier/test_device_condition.py b/tests/components/humidifier/test_device_condition.py index db25bb04970b..5671cce70e06 100644 --- a/tests/components/humidifier/test_device_condition.py +++ b/tests/components/humidifier/test_device_condition.py @@ -21,7 +21,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/humidifier/test_device_trigger.py b/tests/components/humidifier/test_device_trigger.py index 8c4d82e7bfb4..72d1e8c93327 100644 --- a/tests/components/humidifier/test_device_trigger.py +++ b/tests/components/humidifier/test_device_trigger.py @@ -31,7 +31,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/kodi/test_device_trigger.py b/tests/components/kodi/test_device_trigger.py index 7278cb6680eb..140a77839f9a 100644 --- a/tests/components/kodi/test_device_trigger.py +++ b/tests/components/kodi/test_device_trigger.py @@ -16,7 +16,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/light/test_device_action.py b/tests/components/light/test_device_action.py index 5fd33ced82cc..c37827f8f17c 100644 --- a/tests/components/light/test_device_action.py +++ b/tests/components/light/test_device_action.py @@ -24,7 +24,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/light/test_device_condition.py b/tests/components/light/test_device_condition.py index 9b6e38359fd1..5496f93d4b37 100644 --- a/tests/components/light/test_device_condition.py +++ b/tests/components/light/test_device_condition.py @@ -21,7 +21,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/light/test_device_trigger.py b/tests/components/light/test_device_trigger.py index 44a677a5da28..d65c70407614 100644 --- a/tests/components/light/test_device_trigger.py +++ b/tests/components/light/test_device_trigger.py @@ -21,7 +21,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/litejet/test_trigger.py b/tests/components/litejet/test_trigger.py index 40511c3f45ba..d8042ea456ca 100644 --- a/tests/components/litejet/test_trigger.py +++ b/tests/components/litejet/test_trigger.py @@ -14,7 +14,12 @@ import homeassistant.util.dt as dt_util from . import async_init_integration from tests.common import async_fire_time_changed_exact, async_mock_service -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" + _LOGGER = logging.getLogger(__name__) diff --git a/tests/components/lock/test_device_action.py b/tests/components/lock/test_device_action.py index 7619cc61fcf7..56c6f82c6a87 100644 --- a/tests/components/lock/test_device_action.py +++ b/tests/components/lock/test_device_action.py @@ -16,7 +16,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" @pytest.mark.parametrize( diff --git a/tests/components/lock/test_device_condition.py b/tests/components/lock/test_device_condition.py index 4da8efecb561..9440636eebd0 100644 --- a/tests/components/lock/test_device_condition.py +++ b/tests/components/lock/test_device_condition.py @@ -23,7 +23,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/lock/test_device_trigger.py b/tests/components/lock/test_device_trigger.py index 19191883a54d..a761352492dd 100644 --- a/tests/components/lock/test_device_trigger.py +++ b/tests/components/lock/test_device_trigger.py @@ -28,7 +28,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/logbook/conftest.py b/tests/components/logbook/conftest.py index 5b98d7240bec..e2626047d99e 100644 --- a/tests/components/logbook/conftest.py +++ b/tests/components/logbook/conftest.py @@ -1,3 +1,8 @@ """Conftest for script tests.""" -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 +import pytest + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/media_player/test_device_condition.py b/tests/components/media_player/test_device_condition.py index d2e2c3db2890..2bb8d52c75d6 100644 --- a/tests/components/media_player/test_device_condition.py +++ b/tests/components/media_player/test_device_condition.py @@ -24,7 +24,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/media_player/test_device_trigger.py b/tests/components/media_player/test_device_trigger.py index cb25439955b9..1a95fb87aba5 100644 --- a/tests/components/media_player/test_device_trigger.py +++ b/tests/components/media_player/test_device_trigger.py @@ -29,7 +29,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/mqtt/conftest.py b/tests/components/mqtt/conftest.py index 9fe04c459fe6..84861356e08b 100644 --- a/tests/components/mqtt/conftest.py +++ b/tests/components/mqtt/conftest.py @@ -2,10 +2,14 @@ import pytest -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 from tests.components.light.conftest import mock_light_profiles # noqa: F401 +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" + + @pytest.fixture(autouse=True) def patch_hass_config(mock_hass_config: None) -> None: """Patch configuration.yaml.""" diff --git a/tests/components/mqtt/test_device_trigger.py b/tests/components/mqtt/test_device_trigger.py index bcfd55488bc3..e37e7c6cb870 100644 --- a/tests/components/mqtt/test_device_trigger.py +++ b/tests/components/mqtt/test_device_trigger.py @@ -22,10 +22,14 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 from tests.typing import MqttMockHAClient, MqttMockHAClientGenerator, WebSocketGenerator +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" + + @pytest.fixture def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" diff --git a/tests/components/mqtt/test_trigger.py b/tests/components/mqtt/test_trigger.py index 83868c3387cc..675acf7750de 100644 --- a/tests/components/mqtt/test_trigger.py +++ b/tests/components/mqtt/test_trigger.py @@ -9,7 +9,11 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from tests.common import async_fire_mqtt_message, async_mock_service, mock_component -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/number/test_device_action.py b/tests/components/number/test_device_action.py index 987c14409484..7d6e4a8520c2 100644 --- a/tests/components/number/test_device_action.py +++ b/tests/components/number/test_device_action.py @@ -21,7 +21,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" async def test_get_actions( diff --git a/tests/components/philips_js/test_device_trigger.py b/tests/components/philips_js/test_device_trigger.py index a0c2ed34c1ea..ae492ade3ece 100644 --- a/tests/components/philips_js/test_device_trigger.py +++ b/tests/components/philips_js/test_device_trigger.py @@ -12,7 +12,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/remote/test_device_action.py b/tests/components/remote/test_device_action.py index 15ff75e02c07..13c6da5f778e 100644 --- a/tests/components/remote/test_device_action.py +++ b/tests/components/remote/test_device_action.py @@ -16,7 +16,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/remote/test_device_condition.py b/tests/components/remote/test_device_condition.py index 30732c77fe31..ba6cc9fe4f46 100644 --- a/tests/components/remote/test_device_condition.py +++ b/tests/components/remote/test_device_condition.py @@ -21,7 +21,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/remote/test_device_trigger.py b/tests/components/remote/test_device_trigger.py index cd35073280a8..2f39486a0fee 100644 --- a/tests/components/remote/test_device_trigger.py +++ b/tests/components/remote/test_device_trigger.py @@ -21,7 +21,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/script/conftest.py b/tests/components/script/conftest.py index 5b98d7240bec..e2626047d99e 100644 --- a/tests/components/script/conftest.py +++ b/tests/components/script/conftest.py @@ -1,3 +1,8 @@ """Conftest for script tests.""" -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 +import pytest + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/search/test_init.py b/tests/components/search/test_init.py index 31de817c60f0..e6f7d656f733 100644 --- a/tests/components/search/test_init.py +++ b/tests/components/search/test_init.py @@ -1,4 +1,6 @@ """Tests for Search integration.""" +import pytest + from homeassistant.components import search from homeassistant.core import HomeAssistant from homeassistant.helpers import ( @@ -10,9 +12,14 @@ from homeassistant.helpers import ( from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 from tests.typing import WebSocketGenerator + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" + + MOCK_ENTITY_SOURCES = { "light.platform_config_source": { "source": entity.SOURCE_PLATFORM_CONFIG, diff --git a/tests/components/sensor/test_device_condition.py b/tests/components/sensor/test_device_condition.py index 5e93bf2a64c4..90479927d57c 100644 --- a/tests/components/sensor/test_device_condition.py +++ b/tests/components/sensor/test_device_condition.py @@ -26,10 +26,14 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 from tests.testing_config.custom_components.test.sensor import UNITS_OF_MEASUREMENT +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" + + @pytest.fixture def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" diff --git a/tests/components/sensor/test_device_trigger.py b/tests/components/sensor/test_device_trigger.py index 37f44a5b40dc..e360117987c0 100644 --- a/tests/components/sensor/test_device_trigger.py +++ b/tests/components/sensor/test_device_trigger.py @@ -30,10 +30,14 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 from tests.testing_config.custom_components.test.sensor import UNITS_OF_MEASUREMENT +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" + + @pytest.fixture def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" diff --git a/tests/components/sun/test_trigger.py b/tests/components/sun/test_trigger.py index 08910010d5f3..988e069ac5ff 100644 --- a/tests/components/sun/test_trigger.py +++ b/tests/components/sun/test_trigger.py @@ -19,7 +19,11 @@ from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from tests.common import async_fire_time_changed, async_mock_service, mock_component -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/switch/test_device_action.py b/tests/components/switch/test_device_action.py index a52474fe0307..fbdd2716fcad 100644 --- a/tests/components/switch/test_device_action.py +++ b/tests/components/switch/test_device_action.py @@ -16,7 +16,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/switch/test_device_condition.py b/tests/components/switch/test_device_condition.py index e15074b1f31e..08e4487750d6 100644 --- a/tests/components/switch/test_device_condition.py +++ b/tests/components/switch/test_device_condition.py @@ -21,7 +21,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/switch/test_device_trigger.py b/tests/components/switch/test_device_trigger.py index 8d55149648aa..c971a9011c95 100644 --- a/tests/components/switch/test_device_trigger.py +++ b/tests/components/switch/test_device_trigger.py @@ -21,7 +21,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/tag/test_trigger.py b/tests/components/tag/test_trigger.py index 5fe0664f5d90..12c072b8f2b8 100644 --- a/tests/components/tag/test_trigger.py +++ b/tests/components/tag/test_trigger.py @@ -9,7 +9,11 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from tests.common import async_mock_service -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/tasmota/test_device_trigger.py b/tests/components/tasmota/test_device_trigger.py index c999e6c06838..05e31daa441d 100644 --- a/tests/components/tasmota/test_device_trigger.py +++ b/tests/components/tasmota/test_device_trigger.py @@ -22,10 +22,14 @@ from tests.common import ( async_fire_mqtt_message, async_get_device_automations, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 from tests.typing import MqttMockHAClient, WebSocketGenerator +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" + + async def test_get_triggers_btn( hass: HomeAssistant, device_reg, diff --git a/tests/components/text/test_device_action.py b/tests/components/text/test_device_action.py index d3e50103e706..bcc35cd66d4a 100644 --- a/tests/components/text/test_device_action.py +++ b/tests/components/text/test_device_action.py @@ -21,7 +21,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" async def test_get_actions( diff --git a/tests/components/trace/conftest.py b/tests/components/trace/conftest.py index f84f60e38fb0..d5f8dece98f3 100644 --- a/tests/components/trace/conftest.py +++ b/tests/components/trace/conftest.py @@ -1,3 +1,8 @@ """Conftest for trace tests.""" -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 +import pytest + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/update/test_device_trigger.py b/tests/components/update/test_device_trigger.py index 9f55e85dbe9e..768d3efede10 100644 --- a/tests/components/update/test_device_trigger.py +++ b/tests/components/update/test_device_trigger.py @@ -20,7 +20,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/vacuum/test_device_action.py b/tests/components/vacuum/test_device_action.py index 82ebab62155e..b4bef23e9389 100644 --- a/tests/components/vacuum/test_device_action.py +++ b/tests/components/vacuum/test_device_action.py @@ -16,7 +16,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" async def test_get_actions( diff --git a/tests/components/vacuum/test_device_condition.py b/tests/components/vacuum/test_device_condition.py index 51715fec52f9..ec73ac441b73 100644 --- a/tests/components/vacuum/test_device_condition.py +++ b/tests/components/vacuum/test_device_condition.py @@ -21,7 +21,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/vacuum/test_device_trigger.py b/tests/components/vacuum/test_device_trigger.py index a9cd377b6ebe..62ced757c038 100644 --- a/tests/components/vacuum/test_device_trigger.py +++ b/tests/components/vacuum/test_device_trigger.py @@ -21,7 +21,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/water_heater/test_device_action.py b/tests/components/water_heater/test_device_action.py index ecf719fdc41f..65b04b33351e 100644 --- a/tests/components/water_heater/test_device_action.py +++ b/tests/components/water_heater/test_device_action.py @@ -16,7 +16,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" async def test_get_actions( diff --git a/tests/components/webhook/test_trigger.py b/tests/components/webhook/test_trigger.py index a10b77eb105b..2848a3fb759f 100644 --- a/tests/components/webhook/test_trigger.py +++ b/tests/components/webhook/test_trigger.py @@ -7,10 +7,14 @@ import pytest from homeassistant.core import HomeAssistant, callback from homeassistant.setup import async_setup_component -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 from tests.typing import ClientSessionGenerator +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" + + @pytest.fixture(autouse=True) async def setup_http(hass): """Set up http.""" diff --git a/tests/components/zha/test_device_action.py b/tests/components/zha/test_device_action.py index 6db138ebcded..6566a3093356 100644 --- a/tests/components/zha/test_device_action.py +++ b/tests/components/zha/test_device_action.py @@ -24,7 +24,12 @@ from tests.common import ( async_mock_service, mock_coro, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" + SHORT_PRESS = "remote_button_short_press" COMMAND = "command" diff --git a/tests/components/zha/test_device_trigger.py b/tests/components/zha/test_device_trigger.py index 29920eab8369..3538ba44c556 100644 --- a/tests/components/zha/test_device_trigger.py +++ b/tests/components/zha/test_device_trigger.py @@ -24,7 +24,12 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" + ON = 1 OFF = 0 diff --git a/tests/components/zone/test_trigger.py b/tests/components/zone/test_trigger.py index 6a7e07cc998d..c359136ac08c 100644 --- a/tests/components/zone/test_trigger.py +++ b/tests/components/zone/test_trigger.py @@ -8,7 +8,11 @@ from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from tests.common import async_mock_service, mock_component -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate): + """Stub copying the blueprints to the config folder.""" @pytest.fixture From e09e4f14d6dfc1974dce8318976d0258936619f3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 25 May 2023 07:34:07 -0500 Subject: [PATCH 569/822] Speed up setting state (#93472) --- homeassistant/core.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index b2a00cce038e..747affef76fc 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -25,6 +25,7 @@ import os import pathlib import re import threading +import time from time import monotonic from typing import ( TYPE_CHECKING, @@ -1620,10 +1621,24 @@ class StateMachine: if same_state and same_attr: return - now = dt_util.utcnow() - if context is None: - context = Context(id=ulid_util.ulid_at_time(dt_util.utc_to_timestamp(now))) + # It is much faster to convert a timestamp to a utc datetime object + # than converting a utc datetime object to a timestamp since cpython + # does not have a fast path for handling the UTC timezone and has to do + # multiple local timezone conversions. + # + # from_timestamp implementation: + # https://github.com/python/cpython/blob/c90a862cdcf55dc1753c6466e5fa4a467a13ae24/Modules/_datetimemodule.c#L2936 + # + # timestamp implementation: + # https://github.com/python/cpython/blob/c90a862cdcf55dc1753c6466e5fa4a467a13ae24/Modules/_datetimemodule.c#L6387 + # https://github.com/python/cpython/blob/c90a862cdcf55dc1753c6466e5fa4a467a13ae24/Modules/_datetimemodule.c#L6323 + timestamp = time.time() + now = dt_util.utc_from_timestamp(timestamp) + context = Context(id=ulid_util.ulid_at_time(timestamp)) + else: + now = dt_util.utcnow() + state = State( entity_id, new_state, From a96215bf2ee38162e32f40d0a21974e21fc3b9b1 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 25 May 2023 14:48:16 +0200 Subject: [PATCH 570/822] Lastfm config flow (#92299) * Move constant values to separate file * Move constant values to separate file * Add config flow to lastfm * Add tests * Add config flow to lastfm * Add tests * Add tests * Add tests * Add extra form for main user and autofill with friends * Add extra form for main user and autofill with friends * Add extra form for main user and autofill with friends * Add extra form for main user and autofill with friends * Add OptionsFlow * Add tests * Fix feedback * Fix feedback * Fix feedback * Fix feedback * Fix test * Apply suggestions from code review Co-authored-by: G Johansson * Update config_flow.py * Update config_flow.py * Update config_flow.py * Update homeassistant/components/lastfm/config_flow.py Co-authored-by: G Johansson * Add tests * Cleanup * Update config_flow.py * Update config_flow.py * Update config_flow.py * Fix test * Fix feedback * Codeowner lastfm * Fix feedback * Fix feedback * Parametrize errors * Parametrize errors * Parametrize errors * Finish tests --------- Co-authored-by: G Johansson --- CODEOWNERS | 2 + homeassistant/components/lastfm/__init__.py | 26 ++ .../components/lastfm/config_flow.py | 223 +++++++++++++ homeassistant/components/lastfm/const.py | 4 + homeassistant/components/lastfm/manifest.json | 3 +- homeassistant/components/lastfm/sensor.py | 72 ++++- homeassistant/components/lastfm/strings.json | 45 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- tests/components/lastfm/__init__.py | 86 +++++ tests/components/lastfm/test_config_flow.py | 305 ++++++++++++++++++ tests/components/lastfm/test_init.py | 36 +++ tests/components/lastfm/test_sensor.py | 97 ++---- 13 files changed, 808 insertions(+), 94 deletions(-) create mode 100644 homeassistant/components/lastfm/config_flow.py create mode 100644 homeassistant/components/lastfm/strings.json create mode 100644 tests/components/lastfm/test_config_flow.py create mode 100644 tests/components/lastfm/test_init.py diff --git a/CODEOWNERS b/CODEOWNERS index 9954bdc3a2d8..fd7d0725b5bc 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -651,6 +651,8 @@ build.json @home-assistant/supervisor /tests/components/lametric/ @robbiet480 @frenck @bachya /homeassistant/components/landisgyr_heat_meter/ @vpathuis /tests/components/landisgyr_heat_meter/ @vpathuis +/homeassistant/components/lastfm/ @joostlek +/tests/components/lastfm/ @joostlek /homeassistant/components/launch_library/ @ludeeus @DurgNomis-drol /tests/components/launch_library/ @ludeeus @DurgNomis-drol /homeassistant/components/laundrify/ @xLarry diff --git a/homeassistant/components/lastfm/__init__.py b/homeassistant/components/lastfm/__init__.py index 201283429317..fc26dd85ea32 100644 --- a/homeassistant/components/lastfm/__init__.py +++ b/homeassistant/components/lastfm/__init__.py @@ -1 +1,27 @@ """The lastfm component.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import PLATFORMS + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up lastfm from a config entry.""" + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(update_listener)) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload lastfm config entry.""" + + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/lastfm/config_flow.py b/homeassistant/components/lastfm/config_flow.py new file mode 100644 index 000000000000..f7d7a9fd3144 --- /dev/null +++ b/homeassistant/components/lastfm/config_flow.py @@ -0,0 +1,223 @@ +"""Config flow for LastFm.""" +from __future__ import annotations + +from typing import Any + +from pylast import LastFMNetwork, User, WSError +import voluptuous as vol + +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + OptionsFlowWithConfigEntry, +) +from homeassistant.const import CONF_API_KEY +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, +) +from homeassistant.helpers.typing import ConfigType + +from .const import CONF_MAIN_USER, CONF_USERS, DOMAIN + +PLACEHOLDERS = {"api_account_url": "https://www.last.fm/api/account/create"} + +CONFIG_SCHEMA: vol.Schema = vol.Schema( + { + vol.Required(CONF_API_KEY): str, + vol.Required(CONF_MAIN_USER): str, + } +) + + +def get_lastfm_user(api_key: str, username: str) -> tuple[User, dict[str, str]]: + """Get and validate lastFM User.""" + user = LastFMNetwork(api_key=api_key).get_user(username) + errors = {} + try: + user.get_playcount() + except WSError as error: + if error.details == "User not found": + errors["base"] = "invalid_account" + elif ( + error.details + == "Invalid API key - You must be granted a valid key by last.fm" + ): + errors["base"] = "invalid_auth" + else: + errors["base"] = "unknown" + except Exception: # pylint:disable=broad-except + errors["base"] = "unknown" + return user, errors + + +def validate_lastfm_users( + api_key: str, usernames: list[str] +) -> tuple[list[str], dict[str, str]]: + """Validate list of users. Return tuple of valid users and errors.""" + valid_users = [] + errors = {} + for username in usernames: + _, lastfm_errors = get_lastfm_user(api_key, username) + if lastfm_errors: + errors = lastfm_errors + else: + valid_users.append(username) + return valid_users, errors + + +class LastFmConfigFlowHandler(ConfigFlow, domain=DOMAIN): + """Config flow handler for LastFm.""" + + data: dict[str, Any] = {} + + @staticmethod + @callback + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> LastFmOptionsFlowHandler: + """Get the options flow for this handler.""" + return LastFmOptionsFlowHandler(config_entry) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Initialize user input.""" + errors: dict[str, str] = {} + if user_input is not None: + self.data = user_input.copy() + _, errors = get_lastfm_user( + self.data[CONF_API_KEY], self.data[CONF_MAIN_USER] + ) + if not errors: + return await self.async_step_friends() + return self.async_show_form( + step_id="user", + errors=errors, + description_placeholders=PLACEHOLDERS, + data_schema=self.add_suggested_values_to_schema(CONFIG_SCHEMA, user_input), + ) + + async def async_step_friends( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Form to select other users and friends.""" + errors: dict[str, str] = {} + if user_input is not None: + users, errors = validate_lastfm_users( + self.data[CONF_API_KEY], user_input[CONF_USERS] + ) + user_input[CONF_USERS] = users + if not errors: + return self.async_create_entry( + title="LastFM", + data={}, + options={ + CONF_API_KEY: self.data[CONF_API_KEY], + CONF_MAIN_USER: self.data[CONF_MAIN_USER], + CONF_USERS: [ + self.data[CONF_MAIN_USER], + *user_input[CONF_USERS], + ], + }, + ) + try: + main_user, _ = get_lastfm_user( + self.data[CONF_API_KEY], self.data[CONF_MAIN_USER] + ) + friends = [ + SelectOptionDict(value=friend.name, label=friend.get_name(True)) + for friend in main_user.get_friends() + ] + except WSError: + friends = [] + return self.async_show_form( + step_id="friends", + errors=errors, + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_USERS): SelectSelector( + SelectSelectorConfig( + options=friends, custom_value=True, multiple=True + ) + ), + } + ), + user_input or {CONF_USERS: []}, + ), + ) + + async def async_step_import(self, import_config: ConfigType) -> FlowResult: + """Import config from yaml.""" + for entry in self._async_current_entries(): + if entry.options[CONF_API_KEY] == import_config[CONF_API_KEY]: + return self.async_abort(reason="already_configured") + users, _ = validate_lastfm_users( + import_config[CONF_API_KEY], import_config[CONF_USERS] + ) + return self.async_create_entry( + title="LastFM", + data={}, + options={ + CONF_API_KEY: import_config[CONF_API_KEY], + CONF_MAIN_USER: None, + CONF_USERS: users, + }, + ) + + +class LastFmOptionsFlowHandler(OptionsFlowWithConfigEntry): + """LastFm Options flow handler.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Initialize form.""" + errors: dict[str, str] = {} + if user_input is not None: + users, errors = validate_lastfm_users( + self.options[CONF_API_KEY], user_input[CONF_USERS] + ) + user_input[CONF_USERS] = users + if not errors: + return self.async_create_entry( + title="LastFM", + data={ + **self.options, + CONF_USERS: user_input[CONF_USERS], + }, + ) + if self.options[CONF_MAIN_USER]: + try: + main_user, _ = get_lastfm_user( + self.options[CONF_API_KEY], + self.options[CONF_MAIN_USER], + ) + friends = [ + SelectOptionDict(value=friend.name, label=friend.get_name(True)) + for friend in main_user.get_friends() + ] + except WSError: + friends = [] + else: + friends = [] + return self.async_show_form( + step_id="init", + errors=errors, + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_USERS): SelectSelector( + SelectSelectorConfig( + options=friends, custom_value=True, multiple=True + ) + ), + } + ), + user_input or self.options, + ), + ) diff --git a/homeassistant/components/lastfm/const.py b/homeassistant/components/lastfm/const.py index 2a7f40b99e3a..f895876c3c39 100644 --- a/homeassistant/components/lastfm/const.py +++ b/homeassistant/components/lastfm/const.py @@ -2,10 +2,14 @@ import logging from typing import Final +from homeassistant.const import Platform + LOGGER = logging.getLogger(__package__) DOMAIN: Final = "lastfm" +PLATFORMS = [Platform.SENSOR] DEFAULT_NAME = "LastFM" +CONF_MAIN_USER = "main_user" CONF_USERS = "users" ATTR_LAST_PLAYED = "last_played" diff --git a/homeassistant/components/lastfm/manifest.json b/homeassistant/components/lastfm/manifest.json index 392da95a2ac8..4315f4c53899 100644 --- a/homeassistant/components/lastfm/manifest.json +++ b/homeassistant/components/lastfm/manifest.json @@ -1,7 +1,8 @@ { "domain": "lastfm", "name": "Last.fm", - "codeowners": [], + "codeowners": ["@joostlek"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/lastfm", "iot_class": "cloud_polling", "loggers": ["pylast"], diff --git a/homeassistant/components/lastfm/sensor.py b/homeassistant/components/lastfm/sensor.py index 20c51f8a8c6a..d8cf96be5ac6 100644 --- a/homeassistant/components/lastfm/sensor.py +++ b/homeassistant/components/lastfm/sensor.py @@ -7,10 +7,14 @@ from pylast import LastFMNetwork, Track, User, WSError import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ( @@ -18,6 +22,8 @@ from .const import ( ATTR_PLAY_COUNT, ATTR_TOP_PLAYED, CONF_USERS, + DEFAULT_NAME, + DOMAIN, LOGGER, STATE_NOT_SCROBBLING, ) @@ -35,23 +41,46 @@ def format_track(track: Track) -> str: return f"{track.artist} - {track.title}" -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the Last.fm sensor platform.""" - lastfm_api = LastFMNetwork(api_key=config[CONF_API_KEY]) - entities = [] - for username in config[CONF_USERS]: - try: - user = lastfm_api.get_user(username) - entities.append(LastFmSensor(user, lastfm_api)) - except WSError as exc: - LOGGER.error("Failed to load LastFM user `%s`: %r", username, exc) - return - add_entities(entities, True) + """Set up the Last.fm sensor platform from yaml.""" + + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml", + breaks_in_ha_version="2023.8.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + ) + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + ) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Initialize the entries.""" + + lastfm_api = LastFMNetwork(api_key=entry.options[CONF_API_KEY]) + async_add_entities( + ( + LastFmSensor(lastfm_api.get_user(user), entry.entry_id) + for user in entry.options[CONF_USERS] + ), + True, + ) class LastFmSensor(SensorEntity): @@ -60,14 +89,27 @@ class LastFmSensor(SensorEntity): _attr_attribution = "Data provided by Last.fm" _attr_icon = "mdi:radio-fm" - def __init__(self, user: User, lastfm_api: LastFMNetwork) -> None: + def __init__(self, user: User, entry_id: str) -> None: """Initialize the sensor.""" + self._user = user self._attr_unique_id = hashlib.sha256(user.name.encode("utf-8")).hexdigest() self._attr_name = user.name - self._user = user + self._attr_device_info = DeviceInfo( + configuration_url="https://www.last.fm", + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, f"{entry_id}_{self._attr_unique_id}")}, + manufacturer=DEFAULT_NAME, + name=f"{DEFAULT_NAME} {user.name}", + ) def update(self) -> None: """Update device state.""" + try: + self._user.get_playcount() + except WSError as exc: + self._attr_available = False + LOGGER.error("Failed to load LastFM user `%s`: %r", self._user.name, exc) + return self._attr_entity_picture = self._user.get_image() if now_playing := self._user.get_now_playing(): self._attr_native_value = format_track(now_playing) diff --git a/homeassistant/components/lastfm/strings.json b/homeassistant/components/lastfm/strings.json new file mode 100644 index 000000000000..f9156bed658c --- /dev/null +++ b/homeassistant/components/lastfm/strings.json @@ -0,0 +1,45 @@ +{ + "config": { + "step": { + "user": { + "description": "Request an API account at {api_account_url}.", + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]", + "main_user": "Last.fm username" + } + }, + "friends": { + "description": "Fill in other users you want to add.", + "data": { + "users": "Last.fm usernames" + } + } + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_account": "Invalid username", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + }, + "options": { + "step": { + "init": { + "description": "Fill in other users you want to add.", + "data": { + "users": "Last.fm usernames" + } + } + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_account": "Invalid username", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + }, + "issues": { + "deprecated_yaml": { + "title": "The LastFM YAML configuration is being removed", + "description": "Configuring LastFM using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the LastFM YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 399e54c3b557..ad445c6e7395 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -234,6 +234,7 @@ FLOWS = { "lacrosse_view", "lametric", "landisgyr_heat_meter", + "lastfm", "launch_library", "laundrify", "ld2410_ble", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index c97e229f5a34..76a0f69c0888 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2846,7 +2846,7 @@ "lastfm": { "name": "Last.fm", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "launch_library": { diff --git a/tests/components/lastfm/__init__.py b/tests/components/lastfm/__init__.py index 4e7cfffa833a..568983f400dc 100644 --- a/tests/components/lastfm/__init__.py +++ b/tests/components/lastfm/__init__.py @@ -1 +1,87 @@ """The tests for lastfm.""" +from unittest.mock import patch + +from pylast import Track, WSError + +from homeassistant.components.lastfm.const import CONF_MAIN_USER, CONF_USERS +from homeassistant.const import CONF_API_KEY + +API_KEY = "asdasdasdasdasd" +USERNAME_1 = "testaccount1" +USERNAME_2 = "testaccount2" + +CONF_DATA = { + CONF_API_KEY: API_KEY, + CONF_MAIN_USER: USERNAME_1, + CONF_USERS: [USERNAME_1, USERNAME_2], +} +CONF_USER_DATA = {CONF_API_KEY: API_KEY, CONF_MAIN_USER: USERNAME_1} +CONF_FRIENDS_DATA = {CONF_USERS: [USERNAME_2]} + + +class MockNetwork: + """Mock _Network object for pylast.""" + + def __init__(self, username: str): + """Initialize the mock.""" + self.username = username + + +class MockUser: + """Mock User object for pylast.""" + + def __init__(self, now_playing_result, error, has_friends, username): + """Initialize the mock.""" + self._now_playing_result = now_playing_result + self._thrown_error = error + self._has_friends = has_friends + self.name = username + + def get_name(self, capitalized: bool) -> str: + """Get name of the user.""" + return self.name + + def get_playcount(self): + """Get mock play count.""" + if self._thrown_error: + raise self._thrown_error + return 1 + + def get_image(self): + """Get mock image.""" + + def get_recent_tracks(self, limit): + """Get mock recent tracks.""" + return [] + + def get_top_tracks(self, limit): + """Get mock top tracks.""" + return [] + + def get_now_playing(self): + """Get mock now playing.""" + return self._now_playing_result + + def get_friends(self): + """Get mock friends.""" + if self._has_friends is False: + raise WSError("network", "status", "Page not found") + return [MockUser(None, None, True, USERNAME_2)] + + +def patch_fetch_user( + now_playing: Track | None = None, + thrown_error: Exception | None = None, + has_friends: bool = True, + username: str = USERNAME_1, +) -> MockUser: + """Patch interface.""" + return patch( + "pylast.User", + return_value=MockUser(now_playing, thrown_error, has_friends, username), + ) + + +def patch_setup_entry() -> bool: + """Patch interface.""" + return patch("homeassistant.components.lastfm.async_setup_entry", return_value=True) diff --git a/tests/components/lastfm/test_config_flow.py b/tests/components/lastfm/test_config_flow.py new file mode 100644 index 000000000000..021684493987 --- /dev/null +++ b/tests/components/lastfm/test_config_flow.py @@ -0,0 +1,305 @@ +"""Test Lastfm config flow.""" +from pylast import WSError +import pytest + +from homeassistant import data_entry_flow +from homeassistant.components.lastfm.const import ( + CONF_MAIN_USER, + CONF_USERS, + DEFAULT_NAME, + 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 . import ( + API_KEY, + CONF_DATA, + CONF_FRIENDS_DATA, + CONF_USER_DATA, + USERNAME_1, + USERNAME_2, + patch_fetch_user, + patch_setup_entry, +) + +from tests.common import MockConfigEntry + + +async def test_full_user_flow(hass: HomeAssistant) -> None: + """Test the full user configuration flow.""" + with patch_fetch_user(), patch_setup_entry(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_USER_DATA, + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert not result["errors"] + assert result["step_id"] == "friends" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=CONF_FRIENDS_DATA + ) + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["title"] == DEFAULT_NAME + assert result["options"] == CONF_DATA + + +@pytest.mark.parametrize( + ("error", "message"), + [ + ( + WSError( + "network", + "status", + "Invalid API key - You must be granted a valid key by last.fm", + ), + "invalid_auth", + ), + (WSError("network", "status", "User not found"), "invalid_account"), + (Exception(), "unknown"), + (WSError("network", "status", "Something strange"), "unknown"), + ], +) +async def test_flow_fails(hass: HomeAssistant, error: Exception, message: str) -> None: + """Test user initialized flow with invalid username.""" + with patch_fetch_user(thrown_error=error): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=CONF_USER_DATA + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"]["base"] == message + + with patch_fetch_user(), patch_setup_entry(): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_USER_DATA, + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert not result["errors"] + assert result["step_id"] == "friends" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=CONF_FRIENDS_DATA + ) + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["title"] == DEFAULT_NAME + assert result["options"] == CONF_DATA + + +async def test_flow_friends_invalid_username(hass: HomeAssistant) -> None: + """Test user initialized flow with invalid username.""" + with patch_fetch_user(), patch_setup_entry(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_USER_DATA, + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "friends" + + with patch_fetch_user(thrown_error=WSError("network", "status", "User not found")): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=CONF_FRIENDS_DATA + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "friends" + assert result["errors"]["base"] == "invalid_account" + + with patch_fetch_user(), patch_setup_entry(): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=CONF_FRIENDS_DATA + ) + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["title"] == DEFAULT_NAME + assert result["options"] == CONF_DATA + + +async def test_flow_friends_no_friends(hass: HomeAssistant) -> None: + """Test options is empty when user has no friends.""" + with patch_fetch_user(has_friends=False), patch_setup_entry(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_USER_DATA, + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "friends" + assert len(result["data_schema"].schema[CONF_USERS].config["options"]) == 0 + + +async def test_import_flow_success(hass: HomeAssistant) -> None: + """Test import flow.""" + with patch_fetch_user(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_API_KEY: API_KEY, CONF_USERS: [USERNAME_1, USERNAME_2]}, + ) + await hass.async_block_till_done() + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["title"] == "LastFM" + assert result["options"] == { + "api_key": "asdasdasdasdasd", + "main_user": None, + "users": ["testaccount1", "testaccount2"], + } + + +async def test_import_flow_already_exist(hass: HomeAssistant) -> None: + """Test import of yaml already exist.""" + + MockConfigEntry( + domain=DOMAIN, + data={}, + options={CONF_API_KEY: API_KEY, CONF_USERS: ["test"]}, + ).add_to_hass(hass) + + with patch_fetch_user(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=CONF_DATA, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_options_flow(hass: HomeAssistant) -> None: + """Test updating options.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={}, + options={ + CONF_API_KEY: API_KEY, + CONF_MAIN_USER: USERNAME_1, + CONF_USERS: [USERNAME_1, USERNAME_2], + }, + ) + entry.add_to_hass(hass) + with patch_fetch_user(): + await hass.config_entries.async_setup(entry.entry_id) + result = await hass.config_entries.options.async_init(entry.entry_id) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_USERS: [USERNAME_1]}, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_API_KEY: API_KEY, + CONF_MAIN_USER: USERNAME_1, + CONF_USERS: [USERNAME_1], + } + + +async def test_options_flow_incorrect_username(hass: HomeAssistant) -> None: + """Test updating options doesn't work with incorrect username.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={}, + options={ + CONF_API_KEY: API_KEY, + CONF_MAIN_USER: USERNAME_1, + CONF_USERS: [USERNAME_1], + }, + ) + entry.add_to_hass(hass) + with patch_fetch_user(): + await hass.config_entries.async_setup(entry.entry_id) + result = await hass.config_entries.options.async_init(entry.entry_id) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "init" + + with patch_fetch_user(thrown_error=WSError("network", "status", "User not found")): + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_USERS: [USERNAME_1]}, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "init" + assert result["errors"]["base"] == "invalid_account" + + with patch_fetch_user(): + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_USERS: [USERNAME_1]}, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_API_KEY: API_KEY, + CONF_MAIN_USER: USERNAME_1, + CONF_USERS: [USERNAME_1], + } + + +async def test_options_flow_from_import(hass: HomeAssistant) -> None: + """Test updating options gained from import.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={}, + options={ + CONF_API_KEY: API_KEY, + CONF_MAIN_USER: None, + CONF_USERS: [USERNAME_1], + }, + ) + entry.add_to_hass(hass) + with patch_fetch_user(): + await hass.config_entries.async_setup(entry.entry_id) + result = await hass.config_entries.options.async_init(entry.entry_id) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "init" + assert len(result["data_schema"].schema[CONF_USERS].config["options"]) == 0 + + +async def test_options_flow_without_friends(hass: HomeAssistant) -> None: + """Test updating options for someone without friends.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={}, + options={ + CONF_API_KEY: API_KEY, + CONF_MAIN_USER: USERNAME_1, + CONF_USERS: [USERNAME_1], + }, + ) + entry.add_to_hass(hass) + with patch_fetch_user(has_friends=False): + await hass.config_entries.async_setup(entry.entry_id) + result = await hass.config_entries.options.async_init(entry.entry_id) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "init" + assert len(result["data_schema"].schema[CONF_USERS].config["options"]) == 0 diff --git a/tests/components/lastfm/test_init.py b/tests/components/lastfm/test_init.py new file mode 100644 index 000000000000..832494f28dee --- /dev/null +++ b/tests/components/lastfm/test_init.py @@ -0,0 +1,36 @@ +"""Test LastFM component setup process.""" +from __future__ import annotations + +from homeassistant.components.lastfm.const import CONF_MAIN_USER, CONF_USERS, DOMAIN +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant + +from . import USERNAME_1, USERNAME_2, patch_fetch_user + +from tests.common import MockConfigEntry + + +async def test_load_unload_entry(hass: HomeAssistant) -> None: + """Test load and unload entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={}, + options={ + CONF_API_KEY: "12345678", + CONF_MAIN_USER: [USERNAME_1], + CONF_USERS: [USERNAME_1, USERNAME_2], + }, + ) + entry.add_to_hass(hass) + with patch_fetch_user(): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.testaccount1") + assert state + + await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.testaccount1") + assert not state diff --git a/tests/components/lastfm/test_sensor.py b/tests/components/lastfm/test_sensor.py index d9de78b8f8a4..06e8e812ca70 100644 --- a/tests/components/lastfm/test_sensor.py +++ b/tests/components/lastfm/test_sensor.py @@ -1,94 +1,37 @@ """Tests for the lastfm sensor.""" -from unittest.mock import patch from pylast import Track -import pytest -from homeassistant.components import sensor -from homeassistant.components.lastfm.const import STATE_NOT_SCROBBLING +from homeassistant.components.lastfm.const import DOMAIN, STATE_NOT_SCROBBLING from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component + +from . import CONF_DATA, MockNetwork, patch_fetch_user + +from tests.common import MockConfigEntry -class MockNetwork: - """Mock _Network object for pylast.""" - - def __init__(self, username: str): - """Initialize the mock.""" - self.username = username - - -class MockUser: - """Mock User object for pylast.""" - - def __init__(self, now_playing_result): - """Initialize the mock.""" - self._now_playing_result = now_playing_result - self.name = "test" - - def get_playcount(self): - """Get mock play count.""" - return 1 - - def get_image(self): - """Get mock image.""" - - def get_recent_tracks(self, limit): - """Get mock recent tracks.""" - return [] - - def get_top_tracks(self, limit): - """Get mock top tracks.""" - return [] - - def get_now_playing(self): - """Get mock now playing.""" - return self._now_playing_result - - -@pytest.fixture(name="lastfm_network") -def lastfm_network_fixture(): - """Create fixture for LastFMNetwork.""" - with patch( - "homeassistant.components.lastfm.sensor.LastFMNetwork" - ) as lastfm_network: - yield lastfm_network - - -async def test_update_not_playing(hass: HomeAssistant, lastfm_network) -> None: +async def test_update_not_playing(hass: HomeAssistant) -> None: """Test update when no playing song.""" - - lastfm_network.return_value.get_user.return_value = MockUser(None) - - assert await async_setup_component( - hass, - sensor.DOMAIN, - {"sensor": {"platform": "lastfm", "api_key": "secret-key", "users": ["test"]}}, - ) - await hass.async_block_till_done() - - entity_id = "sensor.test" + entry = MockConfigEntry(domain=DOMAIN, data={}, options=CONF_DATA) + entry.add_to_hass(hass) + with patch_fetch_user(None): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + entity_id = "sensor.testaccount1" state = hass.states.get(entity_id) assert state.state == STATE_NOT_SCROBBLING -async def test_update_playing(hass: HomeAssistant, lastfm_network) -> None: - """Test update when song playing.""" - - lastfm_network.return_value.get_user.return_value = MockUser( - Track("artist", "title", MockNetwork("test")) - ) - - assert await async_setup_component( - hass, - sensor.DOMAIN, - {"sensor": {"platform": "lastfm", "api_key": "secret-key", "users": ["test"]}}, - ) - await hass.async_block_till_done() - - entity_id = "sensor.test" +async def test_update_playing(hass: HomeAssistant) -> None: + """Test update when playing a song.""" + entry = MockConfigEntry(domain=DOMAIN, data={}, options=CONF_DATA) + entry.add_to_hass(hass) + with patch_fetch_user(Track("artist", "title", MockNetwork("test"))): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + entity_id = "sensor.testaccount1" state = hass.states.get(entity_id) From 6c66af4e41c07ed858ddef2edaee4c69629f7c06 Mon Sep 17 00:00:00 2001 From: Philip Peitsch Date: Thu, 25 May 2023 23:55:44 +1000 Subject: [PATCH 571/822] Add ability to send custom keys to Samsung TV (#83439) * Add SamsungTV Remote entity with support for turn-off and send command * Fix SamsungTV remote tests --- .../components/samsungtv/__init__.py | 2 +- homeassistant/components/samsungtv/bridge.py | 23 +++++ homeassistant/components/samsungtv/entity.py | 32 +++++++ .../components/samsungtv/media_player.py | 62 ++----------- homeassistant/components/samsungtv/remote.py | 45 +++++++++ tests/components/samsungtv/test_remote.py | 93 +++++++++++++++++++ 6 files changed, 204 insertions(+), 53 deletions(-) create mode 100644 homeassistant/components/samsungtv/entity.py create mode 100644 homeassistant/components/samsungtv/remote.py create mode 100644 tests/components/samsungtv/test_remote.py diff --git a/homeassistant/components/samsungtv/__init__.py b/homeassistant/components/samsungtv/__init__.py index a5deb3ca629e..b7d400ce8316 100644 --- a/homeassistant/components/samsungtv/__init__.py +++ b/homeassistant/components/samsungtv/__init__.py @@ -49,7 +49,7 @@ from .const import ( UPNP_SVC_RENDERING_CONTROL, ) -PLATFORMS = [Platform.MEDIA_PLAYER] +PLATFORMS = [Platform.MEDIA_PLAYER, Platform.REMOTE] CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) diff --git a/homeassistant/components/samsungtv/bridge.py b/homeassistant/components/samsungtv/bridge.py index a2558367995a..ba09cf9fe3b1 100644 --- a/homeassistant/components/samsungtv/bridge.py +++ b/homeassistant/components/samsungtv/bridge.py @@ -6,6 +6,7 @@ import asyncio from asyncio.exceptions import TimeoutError as AsyncioTimeoutError from collections.abc import Callable, Iterable, Mapping import contextlib +from datetime import datetime, timedelta from typing import Any, Generic, TypeVar, cast from samsungctl import Remote @@ -43,8 +44,10 @@ from homeassistant.const import ( CONF_TOKEN, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.helpers import entity_component from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac +from homeassistant.util import dt as dt_util from .const import ( CONF_DESCRIPTION, @@ -67,6 +70,13 @@ from .const import ( WEBSOCKET_PORTS, ) +# Since the TV will take a few seconds to go to sleep +# and actually be seen as off, we need to wait just a bit +# more than the next scan interval +SCAN_INTERVAL_PLUS_OFF_TIME = entity_component.DEFAULT_SCAN_INTERVAL + timedelta( + seconds=5 +) + KEY_PRESS_TIMEOUT = 1.2 ENCRYPTED_MODEL_USES_POWER_OFF = {"H6400", "H6410"} @@ -161,6 +171,10 @@ class SamsungTVBridge(ABC): self._update_config_entry: Callable[[Mapping[str, Any]], None] | None = None self._app_list_callback: Callable[[dict[str, str]], None] | None = None + # Mark the end of a shutdown command (need to wait 15 seconds before + # sending the next command to avoid turning the TV back ON). + self._end_of_power_off: datetime | None = None + def register_reauth_callback(self, func: CALLBACK_TYPE) -> None: """Register a callback function.""" self._reauth_callback = func @@ -203,8 +217,17 @@ class SamsungTVBridge(ABC): async def async_send_keys(self, keys: list[str]) -> None: """Send a list of keys to the tv.""" + @property + def power_off_in_progress(self) -> bool: + """Return if power off has been recently requested.""" + return ( + self._end_of_power_off is not None + and self._end_of_power_off > dt_util.utcnow() + ) + async def async_power_off(self) -> None: """Send power off command to remote and close.""" + self._end_of_power_off = dt_util.utcnow() + SCAN_INTERVAL_PLUS_OFF_TIME await self._async_send_power_off() # Force closing of remote session to provide instant UI feedback await self.async_close_remote() diff --git a/homeassistant/components/samsungtv/entity.py b/homeassistant/components/samsungtv/entity.py new file mode 100644 index 000000000000..418feecbf940 --- /dev/null +++ b/homeassistant/components/samsungtv/entity.py @@ -0,0 +1,32 @@ +"""Base SamsungTV Entity.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_MAC, CONF_MODEL, CONF_NAME +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.entity import DeviceInfo, Entity + +from .bridge import SamsungTVBridge +from .const import CONF_MANUFACTURER, DOMAIN + + +class SamsungTVEntity(Entity): + """Defines a base SamsungTV entity.""" + + def __init__(self, *, bridge: SamsungTVBridge, config_entry: ConfigEntry) -> None: + """Initialize the SamsungTV 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 + self._attr_device_info = DeviceInfo( + name=self.name, + manufacturer=config_entry.data.get(CONF_MANUFACTURER), + model=config_entry.data.get(CONF_MODEL), + ) + if self.unique_id: + self._attr_device_info["identifiers"] = {(DOMAIN, self.unique_id)} + if self._mac: + self._attr_device_info["connections"] = { + (dr.CONNECTION_NETWORK_MAC, self._mac) + } diff --git a/homeassistant/components/samsungtv/media_player.py b/homeassistant/components/samsungtv/media_player.py index d4c04942e63c..2f82c979b940 100644 --- a/homeassistant/components/samsungtv/media_player.py +++ b/homeassistant/components/samsungtv/media_player.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio from collections.abc import Coroutine, Sequence -from datetime import datetime, timedelta from typing import Any import async_timeout @@ -31,26 +30,16 @@ from homeassistant.components.media_player import ( MediaType, ) from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_MODEL, CONF_NAME +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import ( - config_validation as cv, - device_registry as dr, - entity_component, -) +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.trigger import PluggableAction -from homeassistant.util import dt as dt_util from .bridge import SamsungTVBridge, SamsungTVWSBridge -from .const import ( - CONF_MANUFACTURER, - CONF_SSDP_RENDERING_CONTROL_LOCATION, - DOMAIN, - LOGGER, -) +from .const import CONF_SSDP_RENDERING_CONTROL_LOCATION, DOMAIN, LOGGER +from .entity import SamsungTVEntity from .triggers.turn_on import async_get_turn_on_trigger SOURCES = {"TV": "KEY_TV", "HDMI": "KEY_HDMI"} @@ -67,12 +56,6 @@ SUPPORT_SAMSUNGTV = ( | MediaPlayerEntityFeature.PLAY_MEDIA ) -# Since the TV will take a few seconds to go to sleep -# and actually be seen as off, we need to wait just a bit -# more than the next scan interval -SCAN_INTERVAL_PLUS_OFF_TIME = entity_component.DEFAULT_SCAN_INTERVAL + timedelta( - seconds=5 -) # Max delay waiting for app_list to return, as some TVs simply ignore the request APP_LIST_DELAY = 3 @@ -86,7 +69,7 @@ async def async_setup_entry( async_add_entities([SamsungTVDevice(bridge, entry)], True) -class SamsungTVDevice(MediaPlayerEntity): +class SamsungTVDevice(SamsungTVEntity, MediaPlayerEntity): """Representation of a Samsung TV.""" _attr_source_list: list[str] @@ -97,9 +80,9 @@ class SamsungTVDevice(MediaPlayerEntity): config_entry: ConfigEntry, ) -> None: """Initialize the Samsung device.""" + super().__init__(bridge=bridge, config_entry=config_entry) self._config_entry = config_entry self._host: str | None = config_entry.data[CONF_HOST] - self._mac: str | None = config_entry.data.get(CONF_MAC) self._ssdp_rendering_control_location: str | None = config_entry.data.get( CONF_SSDP_RENDERING_CONTROL_LOCATION ) @@ -107,8 +90,6 @@ class SamsungTVDevice(MediaPlayerEntity): # Assume that the TV is in Play mode self._playing: bool = True - self._attr_name: str | None = config_entry.data.get(CONF_NAME) - self._attr_unique_id = config_entry.unique_id self._attr_is_volume_muted: bool = False self._attr_device_class = MediaPlayerDeviceClass.TV self._attr_source_list = list(SOURCES) @@ -123,22 +104,6 @@ class SamsungTVDevice(MediaPlayerEntity): if self._ssdp_rendering_control_location: self._attr_supported_features |= MediaPlayerEntityFeature.VOLUME_SET - self._attr_device_info = DeviceInfo( - name=self.name, - manufacturer=config_entry.data.get(CONF_MANUFACTURER), - model=config_entry.data.get(CONF_MODEL), - ) - if self.unique_id: - self._attr_device_info["identifiers"] = {(DOMAIN, self.unique_id)} - if self._mac: - self._attr_device_info["connections"] = { - (dr.CONNECTION_NETWORK_MAC, self._mac) - } - - # Mark the end of a shutdown command (need to wait 15 seconds before - # sending the next command to avoid turning the TV back ON). - self._end_of_power_off: datetime | None = None - self._bridge = bridge self._auth_failed = False self._bridge.register_reauth_callback(self.access_denied) self._bridge.register_app_list_callback(self._app_list_callback) @@ -190,7 +155,7 @@ class SamsungTVDevice(MediaPlayerEntity): if self._auth_failed or self.hass.is_stopping: return old_state = self._attr_state - if self._power_off_in_progress(): + if self._bridge.power_off_in_progress: self._attr_state = MediaPlayerState.OFF else: self._attr_state = ( @@ -333,7 +298,7 @@ class SamsungTVDevice(MediaPlayerEntity): async def _async_launch_app(self, app_id: str) -> None: """Send launch_app to the tv.""" - if self._power_off_in_progress(): + if self._bridge.power_off_in_progress: LOGGER.info("TV is powering off, not sending launch_app command") return assert isinstance(self._bridge, SamsungTVWSBridge) @@ -342,17 +307,11 @@ class SamsungTVDevice(MediaPlayerEntity): async def _async_send_keys(self, keys: list[str]) -> None: """Send a key to the tv and handles exceptions.""" assert keys - if self._power_off_in_progress() and keys[0] != "KEY_POWEROFF": + if self._bridge.power_off_in_progress and keys[0] != "KEY_POWEROFF": LOGGER.info("TV is powering off, not sending keys: %s", keys) return await self._bridge.async_send_keys(keys) - def _power_off_in_progress(self) -> bool: - return ( - self._end_of_power_off is not None - and self._end_of_power_off > dt_util.utcnow() - ) - @property def available(self) -> bool: """Return the availability of the device.""" @@ -362,7 +321,7 @@ class SamsungTVDevice(MediaPlayerEntity): self.state == MediaPlayerState.ON or bool(self._turn_on) or self._mac is not None - or self._power_off_in_progress() + or self._bridge.power_off_in_progress ) async def async_added_to_hass(self) -> None: @@ -378,7 +337,6 @@ class SamsungTVDevice(MediaPlayerEntity): async def async_turn_off(self) -> None: """Turn off media player.""" - self._end_of_power_off = dt_util.utcnow() + SCAN_INTERVAL_PLUS_OFF_TIME await self._bridge.async_power_off() async def async_set_volume_level(self, volume: float) -> None: diff --git a/homeassistant/components/samsungtv/remote.py b/homeassistant/components/samsungtv/remote.py new file mode 100644 index 000000000000..dd9e46dedcb2 --- /dev/null +++ b/homeassistant/components/samsungtv/remote.py @@ -0,0 +1,45 @@ +"""Support for the SamsungTV remote.""" +from __future__ import annotations + +from collections.abc import Iterable +from typing import Any + +from homeassistant.components.remote import ATTR_NUM_REPEATS, RemoteEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, LOGGER +from .entity import SamsungTVEntity + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Samsung TV from a config entry.""" + bridge = hass.data[DOMAIN][entry.entry_id] + async_add_entities([SamsungTVRemote(bridge=bridge, config_entry=entry)], True) + + +class SamsungTVRemote(SamsungTVEntity, RemoteEntity): + """Device that sends commands to a SamsungTV.""" + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the device off.""" + await self._bridge.async_power_off() + + async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None: + """Send a command to a device. + + Supported keys vary between models. + See https://github.com/jaruba/ha-samsungtv-tizen/blob/master/Key_codes.md + """ + if self._bridge.power_off_in_progress: + LOGGER.info("TV is powering off, not sending keys: %s", command) + return + + num_repeats = kwargs[ATTR_NUM_REPEATS] + command_list = list(command) + + for _ in range(num_repeats): + await self._bridge.async_send_keys(command_list) diff --git a/tests/components/samsungtv/test_remote.py b/tests/components/samsungtv/test_remote.py new file mode 100644 index 000000000000..d6c43060b859 --- /dev/null +++ b/tests/components/samsungtv/test_remote.py @@ -0,0 +1,93 @@ +"""The tests for the SamsungTV remote platform.""" +from unittest.mock import Mock + +import pytest +from samsungtvws.encrypted.remote import SamsungTVEncryptedCommand + +from homeassistant.components.remote import ( + ATTR_COMMAND, + DOMAIN as REMOTE_DOMAIN, + SERVICE_SEND_COMMAND, +) +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_samsungtv_entry +from .test_media_player import MOCK_ENTRYDATA_ENCRYPTED_WS + +ENTITY_ID = f"{REMOTE_DOMAIN}.fake" + + +@pytest.mark.usefixtures("remoteencws", "rest_api") +async def test_setup(hass: HomeAssistant) -> None: + """Test setup with basic config.""" + await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) + assert hass.states.get(ENTITY_ID) + + +@pytest.mark.usefixtures("remoteencws", "rest_api") +async def test_unique_id(hass: HomeAssistant) -> None: + """Test unique id.""" + await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) + + entity_registry = er.async_get(hass) + + main = entity_registry.async_get(ENTITY_ID) + assert main.unique_id == "any" + + +@pytest.mark.usefixtures("remoteencws", "rest_api") +async def test_main_services( + hass: HomeAssistant, remoteencws: Mock, caplog: pytest.LogCaptureFixture +) -> None: + """Test for turn_off.""" + await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) + + remoteencws.send_commands.reset_mock() + + assert await hass.services.async_call( + REMOTE_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + # key called + assert remoteencws.send_commands.call_count == 1 + commands = remoteencws.send_commands.call_args_list[0].args[0] + assert len(commands) == 2 + assert isinstance(command := commands[0], SamsungTVEncryptedCommand) + assert command.body["param3"] == "KEY_POWEROFF" + assert isinstance(command := commands[1], SamsungTVEncryptedCommand) + assert command.body["param3"] == "KEY_POWER" + + # commands not sent : power off in progress + remoteencws.send_commands.reset_mock() + assert await hass.services.async_call( + REMOTE_DOMAIN, + SERVICE_SEND_COMMAND, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_COMMAND: ["dash"]}, + blocking=True, + ) + assert "TV is powering off, not sending keys: ['dash']" in caplog.text + remoteencws.send_commands.assert_not_called() + + +@pytest.mark.usefixtures("remoteencws", "rest_api") +async def test_send_command_service(hass: HomeAssistant, remoteencws: Mock) -> None: + """Test the send command.""" + await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) + + assert await hass.services.async_call( + REMOTE_DOMAIN, + SERVICE_SEND_COMMAND, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_COMMAND: ["dash"]}, + blocking=True, + ) + + assert remoteencws.send_commands.call_count == 1 + commands = remoteencws.send_commands.call_args_list[0].args[0] + assert len(commands) == 1 + assert isinstance(command := commands[0], SamsungTVEncryptedCommand) + assert command.body["param3"] == "dash" From eb617e85f3b767a56fa23075009c32f074cc6c01 Mon Sep 17 00:00:00 2001 From: Dominik Date: Thu, 25 May 2023 16:51:57 +0200 Subject: [PATCH 572/822] Use DEFAULT_SCAN_INTERVAL as update interval for glances (#93535) Use default scan interval constant --- homeassistant/components/glances/const.py | 3 ++- homeassistant/components/glances/coordinator.py | 5 ++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/glances/const.py b/homeassistant/components/glances/const.py index b704ab326f41..37da60bdea80 100644 --- a/homeassistant/components/glances/const.py +++ b/homeassistant/components/glances/const.py @@ -1,5 +1,6 @@ """Constants for Glances component.""" +from datetime import timedelta import sys DOMAIN = "glances" @@ -8,7 +9,7 @@ CONF_VERSION = "version" DEFAULT_HOST = "localhost" DEFAULT_PORT = 61208 DEFAULT_VERSION = 3 -DEFAULT_SCAN_INTERVAL = 60 +DEFAULT_SCAN_INTERVAL = timedelta(seconds=60) SUPPORTED_VERSIONS = [2, 3] diff --git a/homeassistant/components/glances/coordinator.py b/homeassistant/components/glances/coordinator.py index 01e498a88979..24a2e23a013f 100644 --- a/homeassistant/components/glances/coordinator.py +++ b/homeassistant/components/glances/coordinator.py @@ -1,5 +1,4 @@ """Coordinator for Glances integration.""" -from datetime import timedelta import logging from typing import Any @@ -10,7 +9,7 @@ from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -30,7 +29,7 @@ class GlancesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): hass, _LOGGER, name=f"{DOMAIN} - {self.host}", - update_interval=timedelta(seconds=60), + update_interval=DEFAULT_SCAN_INTERVAL, ) async def _async_update_data(self) -> dict[str, Any]: From 25a33b6b374cfafc561bc9ee1507945193e6e163 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 25 May 2023 09:52:31 -0500 Subject: [PATCH 573/822] Bump zeroconf to 0.63.0 (#93533) changelog: https://github.com/python-zeroconf/python-zeroconf/compare/0.62.0...0.63.0 performance improvements --- 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 63d8f97d5b60..442f32974678 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.62.0"] + "requirements": ["zeroconf==0.63.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 151f8abef986..9112959935f0 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -52,7 +52,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtcvad==2.0.10 yarl==1.9.2 -zeroconf==0.62.0 +zeroconf==0.63.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 e3527fcf35f6..2d713b42c396 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2727,7 +2727,7 @@ zamg==0.2.2 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.62.0 +zeroconf==0.63.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cd4f6be0b78a..6b4723510ec5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1985,7 +1985,7 @@ youless-api==1.0.1 zamg==0.2.2 # homeassistant.components.zeroconf -zeroconf==0.62.0 +zeroconf==0.63.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From f7404e2ee0bfbdb371d21ba88de9feca19c8b09a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Thu, 25 May 2023 17:18:28 +0200 Subject: [PATCH 574/822] Update aioairzone-cloud to v0.1.6 (#93513) --- homeassistant/components/airzone_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/airzone_cloud/util.py | 8 ++++++++ 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json index 0099b30e74ba..d03fe5913c23 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.1.3"] + "requirements": ["aioairzone-cloud==0.1.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2d713b42c396..500f8bc202eb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -116,7 +116,7 @@ aio_georss_gdacs==0.8 aioairq==0.2.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.1.3 +aioairzone-cloud==0.1.6 # homeassistant.components.airzone aioairzone==0.5.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6b4723510ec5..f67bae0a6b83 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -106,7 +106,7 @@ aio_georss_gdacs==0.8 aioairq==0.2.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.1.3 +aioairzone-cloud==0.1.6 # homeassistant.components.airzone aioairzone==0.5.6 diff --git a/tests/components/airzone_cloud/util.py b/tests/components/airzone_cloud/util.py index 06e5d1b30170..641a143bfde9 100644 --- a/tests/components/airzone_cloud/util.py +++ b/tests/components/airzone_cloud/util.py @@ -12,6 +12,7 @@ from aioairzone_cloud.const import ( API_DEVICE_ID, API_DEVICES, API_DISCONNECTION_DATE, + API_ERRORS, API_FAH, API_GROUPS, API_HUMIDITY, @@ -24,10 +25,12 @@ from aioairzone_cloud.const import ( API_STAT_AP_MAC, API_STAT_CHANNEL, API_STAT_QUALITY, + API_STAT_RSSI, API_STAT_SSID, API_STATUS, API_SYSTEM_NUMBER, API_TYPE, + API_WARNINGS, API_WS_FW, API_WS_ID, API_WS_IDS, @@ -111,6 +114,7 @@ GET_WEBSERVER_MOCK = { API_STATUS: { API_IS_CONNECTED: True, API_STAT_QUALITY: 4, + API_STAT_RSSI: -56, API_CONNECTION_DATE: "2023-05-07T12:55:51.000Z", API_DISCONNECTION_DATE: "2023-01-01T22:26:55.376Z", }, @@ -122,7 +126,9 @@ def mock_get_device_status(device: Device) -> dict[str, Any]: if device.get_id() == "system1": return { + API_ERRORS: [], API_IS_CONNECTED: True, + API_WARNINGS: [], } if device.get_id() == "zone2": return { @@ -132,6 +138,7 @@ def mock_get_device_status(device: Device) -> dict[str, Any]: API_FAH: 77, API_CELSIUS: 25, }, + API_WARNINGS: [], } return { API_HUMIDITY: 30, @@ -140,6 +147,7 @@ def mock_get_device_status(device: Device) -> dict[str, Any]: API_FAH: 68, API_CELSIUS: 20, }, + API_WARNINGS: [], } From a71c097aeb131beccf19ad2311556029dff50063 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 25 May 2023 17:20:54 +0200 Subject: [PATCH 575/822] Revert "Allow setting an entity's name by its device class" (#93539) --- homeassistant/components/aranet/sensor.py | 14 +++----- homeassistant/components/balboa/entity.py | 6 ++-- homeassistant/components/bond/button.py | 5 --- homeassistant/components/brunt/cover.py | 2 +- homeassistant/components/freebox/home_base.py | 4 +-- homeassistant/components/incomfort/sensor.py | 4 --- .../components/modbus/binary_sensor.py | 6 ---- homeassistant/components/modbus/sensor.py | 6 ---- .../components/point/alarm_control_panel.py | 2 +- homeassistant/components/shelly/utils.py | 13 ++----- .../components/system_bridge/__init__.py | 8 ++--- homeassistant/components/tomorrowio/sensor.py | 5 --- .../components/unifiprotect/entity.py | 11 +----- .../components/vizio/media_player.py | 2 -- homeassistant/components/wemo/sensor.py | 4 --- homeassistant/components/zwave_js/entity.py | 6 +--- homeassistant/components/zwave_me/__init__.py | 10 ++++-- homeassistant/helpers/entity.py | 34 +++---------------- homeassistant/helpers/entity_platform.py | 13 ++----- homeassistant/helpers/template_entity.py | 2 -- tests/components/esphome/conftest.py | 19 +++-------- 21 files changed, 35 insertions(+), 141 deletions(-) diff --git a/homeassistant/components/aranet/sensor.py b/homeassistant/components/aranet/sensor.py index 4596a7fd8af1..6ac27b1652b7 100644 --- a/homeassistant/components/aranet/sensor.py +++ b/homeassistant/components/aranet/sensor.py @@ -28,7 +28,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DEVICE_CLASS_NAME, DeviceInfo +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN @@ -105,13 +105,6 @@ def sensor_update_to_bluetooth_data_update( adv: Aranet4Advertisement, ) -> PassiveBluetoothDataUpdate: """Convert a sensor update to a Bluetooth data update.""" - entity_names: dict[PassiveBluetoothEntityKey, str | None] = {} - for key, desc in SENSOR_DESCRIPTIONS.items(): - # PassiveBluetoothDataUpdate does not support DEVICE_CLASS_NAME - # the assert satisfies the type checker and will catch attempts - # to use DEVICE_CLASS_NAME in the entity descriptions. - assert desc.name is not DEVICE_CLASS_NAME - entity_names[_device_key_to_bluetooth_entity_key(adv.device, key)] = desc.name return PassiveBluetoothDataUpdate( devices={adv.device.address: _sensor_device_info_to_hass(adv)}, entity_descriptions={ @@ -124,7 +117,10 @@ def sensor_update_to_bluetooth_data_update( ) for key in SENSOR_DESCRIPTIONS }, - entity_names=entity_names, + entity_names={ + _device_key_to_bluetooth_entity_key(adv.device, key): desc.name + for key, desc in SENSOR_DESCRIPTIONS.items() + }, ) diff --git a/homeassistant/components/balboa/entity.py b/homeassistant/components/balboa/entity.py index 1b2c8d48f0bb..e50c35db4777 100644 --- a/homeassistant/components/balboa/entity.py +++ b/homeassistant/components/balboa/entity.py @@ -4,7 +4,7 @@ from __future__ import annotations from pybalboa import EVENT_UPDATE, SpaClient from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC -from homeassistant.helpers.entity import DeviceClassName, DeviceInfo, Entity +from homeassistant.helpers.entity import DeviceInfo, Entity from .const import DOMAIN @@ -12,9 +12,7 @@ from .const import DOMAIN class BalboaBaseEntity(Entity): """Balboa base entity.""" - def __init__( - self, client: SpaClient, name: str | DeviceClassName | None = None - ) -> None: + def __init__(self, client: SpaClient, name: str | None = None) -> None: """Initialize the control.""" mac = client.mac_address model = client.model diff --git a/homeassistant/components/bond/button.py b/homeassistant/components/bond/button.py index d3fc58a35bea..32b76c6fcae0 100644 --- a/homeassistant/components/bond/button.py +++ b/homeassistant/components/bond/button.py @@ -35,11 +35,6 @@ class BondButtonEntityDescription( ): """Class to describe a Bond Button entity.""" - # BondEntity does not support DEVICE_CLASS_NAME - # Restrict the type to satisfy the type checker and catch attempts - # to use DEVICE_CLASS_NAME in the entity descriptions. - name: str | None = None - STOP_BUTTON = BondButtonEntityDescription( key=Action.STOP, diff --git a/homeassistant/components/brunt/cover.py b/homeassistant/components/brunt/cover.py index 1df34929b1cb..3fb328ab7fbc 100644 --- a/homeassistant/components/brunt/cover.py +++ b/homeassistant/components/brunt/cover.py @@ -88,7 +88,7 @@ class BruntDevice( self._attr_attribution = ATTRIBUTION self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._attr_unique_id)}, # type: ignore[arg-type] - name=self._thing.name, + name=self._attr_name, via_device=(DOMAIN, self._entry_id), manufacturer="Brunt", sw_version=self._thing.fw_version, diff --git a/homeassistant/components/freebox/home_base.py b/homeassistant/components/freebox/home_base.py index f10a9e047f73..c74f072a5be4 100644 --- a/homeassistant/components/freebox/home_base.py +++ b/homeassistant/components/freebox/home_base.py @@ -30,8 +30,8 @@ class FreeboxHomeEntity(Entity): self._node = node self._sub_node = sub_node self._id = node["id"] - self._device_name = node["label"].strip() - self._attr_name = self._device_name + self._attr_name = node["label"].strip() + self._device_name = self._attr_name self._attr_unique_id = f"{self._router.mac}-node_{self._id}" if sub_node is not None: diff --git a/homeassistant/components/incomfort/sensor.py b/homeassistant/components/incomfort/sensor.py index 0205e690c429..b1b391aaaab0 100644 --- a/homeassistant/components/incomfort/sensor.py +++ b/homeassistant/components/incomfort/sensor.py @@ -28,10 +28,6 @@ class IncomfortSensorEntityDescription(SensorEntityDescription): """Describes Incomfort sensor entity.""" extra_key: str | None = None - # IncomfortSensor does not support DEVICE_CLASS_NAME - # Restrict the type to satisfy the type checker and catch attempts - # to use DEVICE_CLASS_NAME in the entity descriptions. - name: str | None = None SENSOR_TYPES: tuple[IncomfortSensorEntityDescription, ...] = ( diff --git a/homeassistant/components/modbus/binary_sensor.py b/homeassistant/components/modbus/binary_sensor.py index f78e3ef9d318..4f416874f9d0 100644 --- a/homeassistant/components/modbus/binary_sensor.py +++ b/homeassistant/components/modbus/binary_sensor.py @@ -14,7 +14,6 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity import DEVICE_CLASS_NAME from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -74,11 +73,6 @@ class ModbusBinarySensor(BasePlatform, RestoreEntity, BinarySensorEntity): # this ensures that idx = bit position of value in result # polling is done with the base class name = self._attr_name if self._attr_name else "modbus_sensor" - - # DataUpdateCoordinator does not support DEVICE_CLASS_NAME - # the assert satisfies the type checker and will catch attempts - # to use DEVICE_CLASS_NAME in _attr_name. - assert name is not DEVICE_CLASS_NAME self._coordinator = DataUpdateCoordinator( hass, _LOGGER, diff --git a/homeassistant/components/modbus/sensor.py b/homeassistant/components/modbus/sensor.py index 7c1c3b0a7913..ca8246577fd4 100644 --- a/homeassistant/components/modbus/sensor.py +++ b/homeassistant/components/modbus/sensor.py @@ -17,7 +17,6 @@ from homeassistant.const import ( CONF_UNIT_OF_MEASUREMENT, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity import DEVICE_CLASS_NAME from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import ( @@ -80,11 +79,6 @@ class ModbusRegisterSensor(BaseStructPlatform, RestoreSensor, SensorEntity): # this ensures that idx = bit position of value in result # polling is done with the base class name = self._attr_name if self._attr_name else "modbus_sensor" - - # DataUpdateCoordinator does not support DEVICE_CLASS_NAME - # the assert satisfies the type checker and will catch attempts - # to use DEVICE_CLASS_NAME in _attr_name. - assert name is not DEVICE_CLASS_NAME self._coordinator = DataUpdateCoordinator( hass, _LOGGER, diff --git a/homeassistant/components/point/alarm_control_panel.py b/homeassistant/components/point/alarm_control_panel.py index 21469c2c5a5c..bfffb9344078 100644 --- a/homeassistant/components/point/alarm_control_panel.py +++ b/homeassistant/components/point/alarm_control_panel.py @@ -67,7 +67,7 @@ class MinutPointAlarmControl(AlarmControlPanelEntity): self._attr_device_info = DeviceInfo( identifiers={(POINT_DOMAIN, home_id)}, manufacturer="Minut", - name=self._home["name"], + name=self._attr_name, ) async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 6b781918b4f6..55c08620e817 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -19,7 +19,6 @@ from homeassistant.helpers.device_registry import ( async_get as dr_async_get, format_mac, ) -from homeassistant.helpers.entity import DEVICE_CLASS_NAME, DeviceClassName from homeassistant.helpers.entity_registry import async_get as er_async_get from homeassistant.helpers.typing import EventType from homeassistant.util.dt import utcnow @@ -73,16 +72,12 @@ def get_number_of_channels(device: BlockDevice, block: Block) -> int: def get_block_entity_name( device: BlockDevice, block: Block | None, - description: str | DeviceClassName | None = None, + description: str | None = None, ) -> str: """Naming for block based switch and sensors.""" channel_name = get_block_channel_name(device, block) if description: - # It's not possible to do string manipulations on DEVICE_CLASS_NAME - # the assert satisfies the type checker and will catch attempts - # to use DEVICE_CLASS_NAME as description. - assert description is not DEVICE_CLASS_NAME return f"{channel_name} {description.lower()}" return channel_name @@ -306,16 +301,12 @@ def get_rpc_channel_name(device: RpcDevice, key: str) -> str: def get_rpc_entity_name( - device: RpcDevice, key: str, description: str | DeviceClassName | None = None + device: RpcDevice, key: str, description: str | None = None ) -> str: """Naming for RPC based switch and sensors.""" channel_name = get_rpc_channel_name(device, key) if description: - # It's not possible to do string manipulations on DEVICE_CLASS_NAME - # the assert satisfies the type checker and will catch attempts - # to use DEVICE_CLASS_NAME as description. - assert description is not DEVICE_CLASS_NAME return f"{channel_name} {description.lower()}" return channel_name diff --git a/homeassistant/components/system_bridge/__init__.py b/homeassistant/components/system_bridge/__init__.py index 1c5d4a4204e1..05e607d56edb 100644 --- a/homeassistant/components/system_bridge/__init__.py +++ b/homeassistant/components/system_bridge/__init__.py @@ -30,7 +30,7 @@ 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.aiohttp_client import async_get_clientsession -from homeassistant.helpers.entity import DEVICE_CLASS_NAME, DeviceClassName, DeviceInfo +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, MODULES @@ -279,17 +279,13 @@ class SystemBridgeEntity(CoordinatorEntity[SystemBridgeDataUpdateCoordinator]): coordinator: SystemBridgeDataUpdateCoordinator, api_port: int, key: str, - name: str | DeviceClassName | None, + name: str | None, ) -> None: """Initialize the System Bridge entity.""" super().__init__(coordinator) self._hostname = coordinator.data.system.hostname self._key = f"{self._hostname}_{key}" - # It's not possible to do string manipulations on DEVICE_CLASS_NAME - # the assert satisfies the type checker and will catch attempts - # to use DEVICE_CLASS_NAME as name. - assert name is not DEVICE_CLASS_NAME self._name = f"{self._hostname} {name}" self._configuration_url = ( f"http://{self._hostname}:{api_port}/app/settings.html" diff --git a/homeassistant/components/tomorrowio/sensor.py b/homeassistant/components/tomorrowio/sensor.py index 23218543744d..2b7d466d2f06 100644 --- a/homeassistant/components/tomorrowio/sensor.py +++ b/homeassistant/components/tomorrowio/sensor.py @@ -32,7 +32,6 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DEVICE_CLASS_NAME from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import slugify from homeassistant.util.unit_conversion import DistanceConverter, SpeedConverter @@ -350,10 +349,6 @@ class BaseTomorrowioSensorEntity(TomorrowioEntity, SensorEntity): """Initialize Tomorrow.io Sensor Entity.""" super().__init__(config_entry, coordinator, api_version) self.entity_description = description - # It's not possible to do string manipulations on DEVICE_CLASS_NAME - # the assert satisfies the type checker and will catch attempts - # to use DEVICE_CLASS_NAME in the entity descriptions. - assert description.name is not DEVICE_CLASS_NAME 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)}" diff --git a/homeassistant/components/unifiprotect/entity.py b/homeassistant/components/unifiprotect/entity.py index 9ca27c7174e2..134b55c4b0dc 100644 --- a/homeassistant/components/unifiprotect/entity.py +++ b/homeassistant/components/unifiprotect/entity.py @@ -22,12 +22,7 @@ from pyunifiprotect.data import ( from homeassistant.core import callback import homeassistant.helpers.device_registry as dr -from homeassistant.helpers.entity import ( - DEVICE_CLASS_NAME, - DeviceInfo, - Entity, - EntityDescription, -) +from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription from .const import ( ATTR_EVENT_ID, @@ -204,10 +199,6 @@ class ProtectDeviceEntity(Entity): self.entity_description = description self._attr_unique_id = f"{self.device.mac}_{description.key}" name = description.name or "" - # It's not possible to do string manipulations on DEVICE_CLASS_NAME - # the assert satisfies the type checker and will catch attempts - # to use DEVICE_CLASS_NAME in the entity descriptions. - assert name is not DEVICE_CLASS_NAME self._attr_name = f"{self.device.display_name} {name.title()}" self._attr_attribution = DEFAULT_ATTRIBUTION diff --git a/homeassistant/components/vizio/media_player.py b/homeassistant/components/vizio/media_player.py index bf52f053b325..a989cea488f8 100644 --- a/homeassistant/components/vizio/media_player.py +++ b/homeassistant/components/vizio/media_player.py @@ -131,8 +131,6 @@ async def async_setup_entry( class VizioDevice(MediaPlayerEntity): """Media Player implementation which performs REST requests to device.""" - _attr_name: str - def __init__( self, config_entry: ConfigEntry, diff --git a/homeassistant/components/wemo/sensor.py b/homeassistant/components/wemo/sensor.py index 3cfc9a16f300..634d4a9e41d0 100644 --- a/homeassistant/components/wemo/sensor.py +++ b/homeassistant/components/wemo/sensor.py @@ -28,10 +28,6 @@ from .wemo_device import DeviceCoordinator class AttributeSensorDescription(SensorEntityDescription): """SensorEntityDescription for WeMo AttributeSensor entities.""" - # AttributeSensor does not support DEVICE_CLASS_NAME - # the assert satisfies the type checker and will catch attempts - # to use DEVICE_CLASS_NAME in the entity descriptions. - name: str | None = None state_conversion: Callable[[StateType], StateType] | None = None unique_id_suffix: str | None = None diff --git a/homeassistant/components/zwave_js/entity.py b/homeassistant/components/zwave_js/entity.py index 08e997170d7f..6fe1a94b896e 100644 --- a/homeassistant/components/zwave_js/entity.py +++ b/homeassistant/components/zwave_js/entity.py @@ -8,7 +8,7 @@ from zwave_js_server.model.value import Value as ZwaveValue, get_value_id_str from homeassistant.config_entries import ConfigEntry from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DEVICE_CLASS_NAME, DeviceInfo, Entity +from homeassistant.helpers.entity import DeviceInfo, Entity from .const import DOMAIN, LOGGER from .discovery import ZwaveDiscoveryInfo @@ -149,10 +149,6 @@ class ZWaveBaseEntity(Entity): and self.entity_description and self.entity_description.name ): - # It's not possible to do string manipulations on DEVICE_CLASS_NAME - # the assert satisfies the type checker and will catch attempts - # to use DEVICE_CLASS_NAME in the entity descriptions. - assert self.entity_description.name is not DEVICE_CLASS_NAME name = self.entity_description.name if name_prefix: diff --git a/homeassistant/components/zwave_me/__init__.py b/homeassistant/components/zwave_me/__init__.py index f49d203cb26a..1740820d0ba5 100644 --- a/homeassistant/components/zwave_me/__init__.py +++ b/homeassistant/components/zwave_me/__init__.py @@ -115,7 +115,7 @@ async def async_setup_platforms( class ZWaveMeEntity(Entity): """Representation of a ZWaveMe device.""" - def __init__(self, controller: ZWaveMeController, device: ZWaveMeData) -> None: + def __init__(self, controller, device): """Initialize the device.""" self.controller = controller self.device = device @@ -124,9 +124,13 @@ class ZWaveMeEntity(Entity): f"{self.controller.config.unique_id}-{self.device.id}" ) self._attr_should_poll = False - self._attr_device_info = DeviceInfo( + + @property + def device_info(self) -> DeviceInfo: + """Return device specific attributes.""" + return DeviceInfo( identifiers={(DOMAIN, self.device.deviceIdentifier)}, - name=device.title, + name=self._attr_name, manufacturer=self.device.manufacturer, sw_version=self.device.firmware, suggested_area=self.device.locationName, diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 3cc655a7fd12..001713505941 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -52,16 +52,6 @@ DATA_ENTITY_SOURCE = "entity_info" SOURCE_CONFIG_ENTRY = "config_entry" SOURCE_PLATFORM_CONFIG = "platform_config" - -class DeviceClassName(Enum): - """Singleton to use device class name.""" - - _singleton = 0 - - -DEVICE_CLASS_NAME = DeviceClassName._singleton # pylint: disable=protected-access - - # Used when converting float states to string: limit precision according to machine # epsilon to make the string representation readable FLOAT_PRECISION = abs(int(math.floor(math.log10(abs(sys.float_info.epsilon))))) - 1 @@ -229,7 +219,7 @@ class EntityDescription: force_update: bool = False icon: str | None = None has_entity_name: bool = False - name: str | DeviceClassName | None = None + name: str | None = None translation_key: str | None = None unit_of_measurement: str | None = None @@ -298,7 +288,7 @@ class Entity(ABC): _attr_extra_state_attributes: MutableMapping[str, Any] _attr_force_update: bool _attr_icon: str | None - _attr_name: str | DeviceClassName | None + _attr_name: str | None _attr_should_poll: bool = True _attr_state: StateType = STATE_UNKNOWN _attr_supported_features: int | None = None @@ -328,24 +318,10 @@ class Entity(ABC): return self.entity_description.has_entity_name return False - def _device_class_name(self) -> str | None: - """Return a translated name of the entity based on its device class.""" - assert self.platform - if not self.has_entity_name: - return None - device_class_key = self.device_class or "_" - name_translation_key = ( - f"component.{self.platform.domain}.entity_component." - f"{device_class_key}.name" - ) - return self.platform.component_translations.get(name_translation_key) - @property def name(self) -> str | None: """Return the name of the entity.""" if hasattr(self, "_attr_name"): - if self._attr_name is DEVICE_CLASS_NAME: - return self._device_class_name() return self._attr_name if self.translation_key is not None and self.has_entity_name: assert self.platform @@ -353,12 +329,10 @@ class Entity(ABC): f"component.{self.platform.platform_name}.entity.{self.platform.domain}" f".{self.translation_key}.name" ) - if name_translation_key in self.platform.platform_translations: - name: str = self.platform.platform_translations[name_translation_key] + if name_translation_key in self.platform.entity_translations: + name: str = self.platform.entity_translations[name_translation_key] return name if hasattr(self, "entity_description"): - if self.entity_description.name is DEVICE_CLASS_NAME: - return self._device_class_name() return self.entity_description.name return None diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 4a2ff020fe28..372f541d4bd5 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -125,8 +125,7 @@ class EntityPlatform: self.entity_namespace = entity_namespace self.config_entry: config_entries.ConfigEntry | None = None self.entities: dict[str, Entity] = {} - self.component_translations: dict[str, Any] = {} - self.platform_translations: dict[str, Any] = {} + self.entity_translations: dict[str, Any] = {} self._tasks: list[asyncio.Task[None]] = [] # Stop tracking tasks after setup is completed self._setup_complete = False @@ -280,15 +279,7 @@ class EntityPlatform: full_name = f"{self.domain}.{self.platform_name}" try: - self.component_translations = await translation.async_get_translations( - hass, hass.config.language, "entity_component", {self.domain} - ) - except Exception as err: # pylint: disable=broad-exception-caught - _LOGGER.debug( - "Could not load translations for %s", self.domain, exc_info=err - ) - try: - self.platform_translations = await translation.async_get_translations( + self.entity_translations = await translation.async_get_translations( hass, hass.config.language, "entity", {self.platform_name} ) except Exception as err: # pylint: disable=broad-exception-caught diff --git a/homeassistant/helpers/template_entity.py b/homeassistant/helpers/template_entity.py index 2b2774eea66d..c4eb8a1343d3 100644 --- a/homeassistant/helpers/template_entity.py +++ b/homeassistant/helpers/template_entity.py @@ -172,8 +172,6 @@ class _TemplateAttribute: class TemplateEntity(Entity): """Entity that uses templates to calculate attributes.""" - _attr_name: str | None - _attr_available = True _attr_entity_picture = None _attr_icon = None diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index 381b9e99f03b..4c01c2f7c5a2 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -1,10 +1,9 @@ """esphome session fixtures.""" from __future__ import annotations -from asyncio import Event from unittest.mock import AsyncMock, Mock, patch -from aioesphomeapi import APIClient, APIVersion, DeviceInfo, ReconnectLogic +from aioesphomeapi import APIClient, APIVersion, DeviceInfo import pytest from zeroconf import Zeroconf @@ -159,18 +158,10 @@ async def mock_voice_assistant_v1_entry( mock_client.device_info = AsyncMock(return_value=device_info) mock_client.subscribe_voice_assistant = AsyncMock(return_value=Mock()) - try_connect_done = Event() - real_try_connect = ReconnectLogic._try_connect - - async def mock_try_connect(self): - """Set an event when ReconnectLogic._try_connect has been awaited.""" - result = await real_try_connect(self) - try_connect_done.set() - return result - - with patch.object(ReconnectLogic, "_try_connect", mock_try_connect): - await hass.config_entries.async_setup(entry.entry_id) - await try_connect_done.wait() + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + await hass.async_block_till_done() + await hass.async_block_till_done() return entry From 70e20815c73c5df92ce98d698cc3ce02651152b1 Mon Sep 17 00:00:00 2001 From: Artem Draft Date: Thu, 25 May 2023 22:47:13 +0300 Subject: [PATCH 576/822] Mark as `assumed_state` LG Netcast media player (#93543) Mark as assumed_state LG Netcast media player --- .../components/lg_netcast/media_player.py | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/lg_netcast/media_player.py b/homeassistant/components/lg_netcast/media_player.py index 2074966e1e7b..c7a5281bf618 100644 --- a/homeassistant/components/lg_netcast/media_player.py +++ b/homeassistant/components/lg_netcast/media_player.py @@ -74,6 +74,7 @@ def setup_platform( class LgTVDevice(MediaPlayerEntity): """Representation of a LG TV.""" + _attr_assumed_state = True _attr_device_class = MediaPlayerDeviceClass.TV _attr_media_content_type = MediaType.CHANNEL @@ -83,8 +84,6 @@ class LgTVDevice(MediaPlayerEntity): self._name = name self._muted = False self._on_action_script = on_action_script - # Assume that the TV is in Play mode - self._playing = True self._volume = 0 self._channel_id = None self._channel_name = "" @@ -106,7 +105,7 @@ class LgTVDevice(MediaPlayerEntity): try: with self._client as client: - self._attr_state = MediaPlayerState.PLAYING + self._attr_state = MediaPlayerState.ON self.__update_volume() @@ -233,23 +232,12 @@ class LgTVDevice(MediaPlayerEntity): """Select input source.""" self._client.change_channel(self._sources[source]) - def media_play_pause(self) -> None: - """Simulate play pause media player.""" - if self._playing: - self.media_pause() - else: - self.media_play() - def media_play(self) -> None: """Send play command.""" - self._playing = True - self._attr_state = MediaPlayerState.PLAYING self.send_command(LG_COMMAND.PLAY) def media_pause(self) -> None: """Send media pause command to media player.""" - self._playing = False - self._attr_state = MediaPlayerState.PAUSED self.send_command(LG_COMMAND.PAUSE) def media_next_track(self) -> None: From f5358d3863a84019c995a32bd3f35ee8fd030ec4 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 25 May 2023 22:15:33 +0200 Subject: [PATCH 577/822] Bump reolink-aio to 0.5.16 (#93540) --- 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 6a4ae98a1546..413c106b53ea 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -18,5 +18,5 @@ "documentation": "https://www.home-assistant.io/integrations/reolink", "iot_class": "local_push", "loggers": ["reolink_aio"], - "requirements": ["reolink-aio==0.5.15"] + "requirements": ["reolink-aio==0.5.16"] } diff --git a/requirements_all.txt b/requirements_all.txt index 500f8bc202eb..9d0d65bf0de9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2254,7 +2254,7 @@ regenmaschine==2023.05.1 renault-api==0.1.13 # homeassistant.components.reolink -reolink-aio==0.5.15 +reolink-aio==0.5.16 # homeassistant.components.python_script restrictedpython==6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f67bae0a6b83..3f9cce831c1b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1635,7 +1635,7 @@ regenmaschine==2023.05.1 renault-api==0.1.13 # homeassistant.components.reolink -reolink-aio==0.5.15 +reolink-aio==0.5.16 # homeassistant.components.python_script restrictedpython==6.0 From eb058a3d41349893df1450cbef3988ef19c7cf91 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 25 May 2023 21:10:44 +0000 Subject: [PATCH 578/822] Add illuminance sensor for Shelly gen2 devices (#93559) --- homeassistant/components/shelly/sensor.py | 8 ++++++++ tests/components/shelly/conftest.py | 1 + tests/components/shelly/test_sensor.py | 10 ++++++++++ 3 files changed, 19 insertions(+) diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index ce77a6e9ff66..4a88157efc64 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -584,6 +584,14 @@ RPC_SENSORS: Final = { state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, ), + "illuminance": RpcSensorDescription( + key="illuminance", + sub_key="lux", + name="Illuminance", + native_unit_of_measurement=LIGHT_LUX, + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + ), "temperature": RpcSensorDescription( key="switch", sub_key="temperature", diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index 8fa5b0eaeac0..2a80233aeb99 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -190,6 +190,7 @@ MOCK_STATUS_RPC = { "apower": 85.3, }, "temperature:0": {"tC": 22.9}, + "illuminance:0": {"lux": 345}, "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 d5da1e96bce7..0b906d600796 100644 --- a/tests/components/shelly/test_sensor.py +++ b/tests/components/shelly/test_sensor.py @@ -274,6 +274,16 @@ async def test_rpc_sensor(hass: HomeAssistant, mock_rpc_device, monkeypatch) -> assert hass.states.get(entity_id).state == STATE_UNKNOWN +async def test_rpc_illuminance_sensor( + hass: HomeAssistant, mock_rpc_device, monkeypatch +) -> None: + """Test RPC illuminacne sensor.""" + entity_id = f"{SENSOR_DOMAIN}.test_name_illuminance" + await init_integration(hass, 2) + + assert hass.states.get(entity_id).state == "345" + + async def test_rpc_sensor_error( hass: HomeAssistant, mock_rpc_device, monkeypatch ) -> None: From e6a214595b9bc5c75eb4ee27132891c6ded4be3c Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 25 May 2023 21:12:25 +0000 Subject: [PATCH 579/822] Avoid KeyError when specifying the consumption type in Shelly integration (#93561) Avoid KeyError when specifying the consumption type --- homeassistant/components/shelly/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 55c08620e817..c929e152b3fa 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -348,7 +348,7 @@ def is_block_channel_type_light(settings: dict[str, Any], channel: int) -> bool: def is_rpc_channel_type_light(config: dict[str, Any], channel: int) -> bool: """Return true if rpc channel consumption type is set to light.""" - con_types = config["sys"]["ui_data"].get("consumption_types") + con_types = config["sys"].get("ui_data", {}).get("consumption_types") return con_types is not None and con_types[channel].lower().startswith("light") From accee4b5ef067027cea01832f4c26fb101c7129e Mon Sep 17 00:00:00 2001 From: Justin Vanderhooft Date: Fri, 26 May 2023 02:06:22 +0100 Subject: [PATCH 580/822] Add support for configuring built-in Melnor Bluetooth scheduling system (#93333) --- homeassistant/components/melnor/manifest.json | 2 +- homeassistant/components/melnor/number.py | 38 +++++- homeassistant/components/melnor/sensor.py | 15 +++ homeassistant/components/melnor/switch.py | 10 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/melnor/conftest.py | 124 ++++++++++++++++-- tests/components/melnor/test_number.py | 82 +++++++++++- tests/components/melnor/test_sensor.py | 46 +++++++ tests/components/melnor/test_switch.py | 39 ++++++ 10 files changed, 335 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/melnor/manifest.json b/homeassistant/components/melnor/manifest.json index 4a95900aeb39..8d5d86a8bf71 100644 --- a/homeassistant/components/melnor/manifest.json +++ b/homeassistant/components/melnor/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/melnor", "iot_class": "local_polling", - "requirements": ["melnor-bluetooth==0.0.20"] + "requirements": ["melnor-bluetooth==0.0.21"] } diff --git a/homeassistant/components/melnor/number.py b/homeassistant/components/melnor/number.py index c750e07f7e84..79b80a6d7b51 100644 --- a/homeassistant/components/melnor/number.py +++ b/homeassistant/components/melnor/number.py @@ -8,9 +8,13 @@ from typing import Any from melnor_bluetooth.device import Valve -from homeassistant.components.number import NumberEntity, NumberEntityDescription +from homeassistant.components.number import ( + NumberEntity, + NumberEntityDescription, + NumberMode, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory +from homeassistant.const import EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -44,10 +48,33 @@ ZONE_ENTITY_DESCRIPTIONS: list[MelnorZoneNumberEntityDescription] = [ native_min_value=1, icon="mdi:timer-cog-outline", key="manual_minutes", - name="Manual Minutes", + name="Manual Duration", + native_unit_of_measurement=UnitOfTime.MINUTES, set_num_fn=lambda valve, value: valve.set_manual_watering_minutes(value), state_fn=lambda valve: valve.manual_watering_minutes, - ) + ), + MelnorZoneNumberEntityDescription( + entity_category=EntityCategory.CONFIG, + native_max_value=168, + native_min_value=1, + icon="mdi:calendar-refresh-outline", + key="frequency_interval_hours", + name="Schedule Interval", + native_unit_of_measurement=UnitOfTime.HOURS, + set_num_fn=lambda valve, value: valve.set_frequency_interval_hours(value), + state_fn=lambda valve: valve.frequency.interval_hours, + ), + MelnorZoneNumberEntityDescription( + entity_category=EntityCategory.CONFIG, + native_max_value=360, + native_min_value=1, + icon="mdi:timer-outline", + key="frequency_duration_minutes", + name="Schedule Duration", + native_unit_of_measurement=UnitOfTime.MINUTES, + set_num_fn=lambda valve, value: valve.set_frequency_duration_minutes(value), + state_fn=lambda valve: valve.frequency.duration_minutes, + ), ] @@ -75,6 +102,7 @@ class MelnorZoneNumber(MelnorZoneEntity, NumberEntity): """A number implementation for a melnor device.""" entity_description: MelnorZoneNumberEntityDescription + _attr_mode = NumberMode.BOX def __init__( self, @@ -88,7 +116,7 @@ class MelnorZoneNumber(MelnorZoneEntity, NumberEntity): @property def native_value(self) -> float | None: """Return the current value.""" - return self._valve.manual_watering_minutes + return self.entity_description.state_fn(self._valve) async def async_set_native_value(self, value: float) -> None: """Update the current value.""" diff --git a/homeassistant/components/melnor/sensor.py b/homeassistant/components/melnor/sensor.py index 1061d084ad1b..b4a1d44a2917 100644 --- a/homeassistant/components/melnor/sensor.py +++ b/homeassistant/components/melnor/sensor.py @@ -45,6 +45,15 @@ def watering_seconds_left(valve: Valve) -> datetime | None: return dt_util.utc_from_timestamp(valve.watering_end_time) +def next_cycle(valve: Valve) -> datetime | None: + """Return the value of the next_cycle date, only if the cycle is enabled.""" + + if valve.schedule_enabled is True: + return valve.next_cycle + + return None + + @dataclass class MelnorSensorEntityDescriptionMixin: """Mixin for required keys.""" @@ -102,6 +111,12 @@ ZONE_ENTITY_DESCRIPTIONS: list[MelnorZoneSensorEntityDescription] = [ name="Manual Cycle End", state_fn=watering_seconds_left, ), + MelnorZoneSensorEntityDescription( + device_class=SensorDeviceClass.TIMESTAMP, + key="next_cycle", + name="Next Cycle", + state_fn=next_cycle, + ), ] diff --git a/homeassistant/components/melnor/switch.py b/homeassistant/components/melnor/switch.py index eca6f1a98cfb..a2854479abdf 100644 --- a/homeassistant/components/melnor/switch.py +++ b/homeassistant/components/melnor/switch.py @@ -47,7 +47,15 @@ ZONE_ENTITY_DESCRIPTIONS = [ key="manual", on_off_fn=lambda valve, bool: valve.set_is_watering(bool), state_fn=lambda valve: valve.is_watering, - ) + ), + MelnorSwitchEntityDescription( + device_class=SwitchDeviceClass.SWITCH, + icon="mdi:calendar-sync-outline", + key="frequency", + name="Schedule", + on_off_fn=lambda valve, bool: valve.set_frequency_enabled(bool), + state_fn=lambda valve: valve.schedule_enabled, + ), ] diff --git a/requirements_all.txt b/requirements_all.txt index 9d0d65bf0de9..ced1c1d6f33e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1121,7 +1121,7 @@ mcstatus==6.0.0 meater-python==0.0.8 # homeassistant.components.melnor -melnor-bluetooth==0.0.20 +melnor-bluetooth==0.0.21 # homeassistant.components.message_bird messagebird==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3f9cce831c1b..a34cc1a09701 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -847,7 +847,7 @@ mcstatus==6.0.0 meater-python==0.0.8 # homeassistant.components.melnor -melnor-bluetooth==0.0.20 +melnor-bluetooth==0.0.21 # homeassistant.components.meteo_france meteofrance-api==1.2.0 diff --git a/tests/components/melnor/conftest.py b/tests/components/melnor/conftest.py index 790301171cd4..ab51bf44a579 100644 --- a/tests/components/melnor/conftest.py +++ b/tests/components/melnor/conftest.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections.abc import Generator +from datetime import datetime, time, timedelta, timezone from unittest.mock import AsyncMock, patch from melnor_bluetooth.device import Device @@ -57,13 +58,78 @@ def mock_bluetooth(enable_bluetooth): """Auto mock bluetooth.""" -class MockedValve: +class MockFrequency: + """Mocked class for a Frequency.""" + + _duration: int + _interval: int + _is_watering: bool + _start_time: time + _next_run_time: datetime + + def __init__(self) -> None: + """Initialize a mocked frequency.""" + self._duration = 0 + self._interval = 0 + self._is_watering = False + self._start_time = time(12, 0) + self._next_run_time = datetime(2021, 1, 1, 12, 0, tzinfo=timezone.utc) + + @property + def duration_minutes(self) -> int: + """Return the duration in minutes.""" + return self._duration + + @duration_minutes.setter + def duration_minutes(self, duration: int) -> None: + """Set the duration in minutes.""" + self._duration = duration + + @property + def interval_hours(self) -> int: + """Return the interval in hours.""" + return self._interval + + @interval_hours.setter + def interval_hours(self, interval: int) -> None: + """Set the interval in hours.""" + self._interval = interval + + @property + def start_time(self) -> time: + """Return the start time.""" + return self._start_time + + @start_time.setter + def start_time(self, start_time: time) -> None: + """Set the start time.""" + self._start_time = start_time + + @property + def is_watering(self) -> bool: + """Return true if the frequency is currently watering.""" + return self._is_watering + + @property + def next_run_time(self) -> datetime: + """Return the next run time.""" + return self._next_run_time + + @property + def schedule_end_time(self) -> datetime: + """Return the schedule end time.""" + return self._next_run_time + timedelta(minutes=self._duration) + + +class MockValve: """Mocked class for a Valve.""" _id: int _is_watering: bool _manual_watering_minutes: int _end_time: int + _frequency: MockFrequency + _schedule_enabled: bool def __init__(self, identifier: int) -> None: """Initialize a mocked valve.""" @@ -71,35 +137,69 @@ class MockedValve: self._id = identifier self._is_watering = False self._manual_watering_minutes = 0 + self._schedule_enabled = False + + self._frequency = MockFrequency() @property def id(self) -> int: """Return the valve id.""" return self._id + @property + def frequency(self): + """Return the frequency.""" + return self._frequency + @property def is_watering(self): """Return true if the valve is currently watering.""" return self._is_watering - async def set_is_watering(self, is_watering: bool): - """Set the valve to manual watering.""" - self._is_watering = is_watering - @property def manual_watering_minutes(self): """Return the number of minutes the valve is set to manual watering.""" return self._manual_watering_minutes - async def set_manual_watering_minutes(self, minutes: int): - """Set the valve to manual watering.""" - self._manual_watering_minutes = minutes + @property + def next_cycle(self): + """Return the end time of the current watering cycle.""" + return self._frequency.next_run_time + + @property + def schedule_enabled(self) -> bool: + """Return true if the schedule is enabled.""" + return self._schedule_enabled @property def watering_end_time(self) -> int: """Return the end time of the current watering cycle.""" return self._end_time + async def set_is_watering(self, is_watering: bool): + """Set the valve to manual watering.""" + self._is_watering = is_watering + + async def set_manual_watering_minutes(self, minutes: int): + """Set the valve to manual watering.""" + self._manual_watering_minutes = minutes + + async def set_frequency_interval_hours(self, interval: int): + """Set the frequency interval in hours.""" + self._frequency.interval_hours = interval + + async def set_frequency_duration_minutes(self, duration: int): + """Set the frequency duration in minutes.""" + self._frequency.duration_minutes = duration + + async def set_frequency_enabled(self, enabled: bool): + """Set the frequency schedule enabled.""" + self._schedule_enabled = enabled + + async def set_frequency_start_time(self, value: time): + """Set the frequency schedule enabled.""" + self._frequency.start_time = value + def mock_config_entry(hass: HomeAssistant): """Return a mock config entry.""" @@ -131,10 +231,10 @@ def mock_melnor_device(): device.name = "test_melnor" device.rssi = -50 - device.zone1 = MockedValve(0) - device.zone2 = MockedValve(1) - device.zone3 = MockedValve(2) - device.zone4 = MockedValve(3) + device.zone1 = MockValve(0) + device.zone2 = MockValve(1) + device.zone3 = MockValve(2) + device.zone4 = MockValve(3) device.__getitem__.side_effect = lambda key: getattr(device, key) diff --git a/tests/components/melnor/test_number.py b/tests/components/melnor/test_number.py index e7d6780aa4c9..a8d358c2ac23 100644 --- a/tests/components/melnor/test_number.py +++ b/tests/components/melnor/test_number.py @@ -12,7 +12,7 @@ from .conftest import ( async def test_manual_watering_minutes(hass: HomeAssistant) -> None: - """Test the manual watering switch.""" + """Test the manual watering duration number.""" entry = mock_config_entry(hass) @@ -22,8 +22,9 @@ async def test_manual_watering_minutes(hass: HomeAssistant) -> None: assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - number = hass.states.get("number.zone_1_manual_minutes") + number = hass.states.get("number.zone_1_manual_duration") + assert number is not None assert number.state == "0" assert number.attributes["max"] == 360 assert number.attributes["min"] == 1 @@ -35,11 +36,84 @@ async def test_manual_watering_minutes(hass: HomeAssistant) -> None: await hass.services.async_call( "number", "set_value", - {"entity_id": "number.zone_1_manual_minutes", "value": 10}, + {"entity_id": "number.zone_1_manual_duration", "value": 10}, blocking=True, ) - number = hass.states.get("number.zone_1_manual_minutes") + number = hass.states.get("number.zone_1_manual_duration") + assert number is not None assert number.state == "10" assert device.zone1.manual_watering_minutes == 10 + + +async def test_frequency_interval_hours(hass: HomeAssistant) -> None: + """Test the interval hours number.""" + + entry = mock_config_entry(hass) + + with patch_async_ble_device_from_address(), patch_melnor_device() as device_patch, patch_async_register_callback(): + device = device_patch.return_value + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + number = hass.states.get("number.zone_1_schedule_interval") + + assert number is not None + assert number.state == "0" + assert number.attributes["max"] == 168 + assert number.attributes["min"] == 1 + assert number.attributes["step"] == 1.0 + assert number.attributes["icon"] == "mdi:calendar-refresh-outline" + + assert device.zone1.frequency.interval_hours == 0 + + await hass.services.async_call( + "number", + "set_value", + {"entity_id": "number.zone_1_schedule_interval", "value": 10}, + blocking=True, + ) + + number = hass.states.get("number.zone_1_schedule_interval") + + assert number is not None + assert number.state == "10" + assert device.zone1.frequency.interval_hours == 10 + + +async def test_frequency_duration_minutes(hass: HomeAssistant) -> None: + """Test the duration minutes number.""" + + entry = mock_config_entry(hass) + + with patch_async_ble_device_from_address(), patch_melnor_device() as device_patch, patch_async_register_callback(): + device = device_patch.return_value + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + number = hass.states.get("number.zone_1_schedule_duration") + + assert number is not None + assert number.state == "0" + assert number.attributes["max"] == 360 + assert number.attributes["min"] == 1 + assert number.attributes["step"] == 1.0 + assert number.attributes["icon"] == "mdi:timer-outline" + + assert device.zone1.frequency.duration_minutes == 0 + + await hass.services.async_call( + "number", + "set_value", + {"entity_id": "number.zone_1_schedule_duration", "value": 10}, + blocking=True, + ) + + number = hass.states.get("number.zone_1_schedule_duration") + + assert number is not None + assert number.state == "10" + assert device.zone1.frequency.duration_minutes == 10 diff --git a/tests/components/melnor/test_sensor.py b/tests/components/melnor/test_sensor.py index b525ec67b17f..291115fae9d6 100644 --- a/tests/components/melnor/test_sensor.py +++ b/tests/components/melnor/test_sensor.py @@ -30,6 +30,8 @@ async def test_battery_sensor(hass: HomeAssistant) -> None: await hass.async_block_till_done() battery_sensor = hass.states.get("sensor.test_melnor_battery") + + assert battery_sensor is not None assert battery_sensor.state == "80" assert battery_sensor.attributes["unit_of_measurement"] == PERCENTAGE assert battery_sensor.attributes["device_class"] == SensorDeviceClass.BATTERY @@ -58,6 +60,8 @@ async def test_minutes_remaining_sensor(hass: HomeAssistant) -> None: # Valve is off, report 0 minutes_sensor = hass.states.get("sensor.zone_1_manual_cycle_end") + + assert minutes_sensor is not None assert minutes_sensor.state == "unknown" assert minutes_sensor.attributes["device_class"] == SensorDeviceClass.TIMESTAMP @@ -69,9 +73,50 @@ async def test_minutes_remaining_sensor(hass: HomeAssistant) -> None: # Valve is on, report 10 minutes_remaining_sensor = hass.states.get("sensor.zone_1_manual_cycle_end") + + assert minutes_remaining_sensor is not None assert minutes_remaining_sensor.state == end_time.isoformat(timespec="seconds") +async def test_schedule_next_cycle_sensor(hass: HomeAssistant) -> None: + """Test the frequency next_cycle sensor.""" + + now = dt_util.utcnow() + + entry = mock_config_entry(hass) + device = mock_melnor_device() + + next_cycle = now + dt_util.dt.timedelta(minutes=10) + + # we control this mock + device.zone1.frequency._next_run_time = next_cycle + + with freeze_time(now), patch_async_ble_device_from_address(), patch_melnor_device( + device + ), patch_async_register_callback(): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + # Valve is off, report 0 + minutes_sensor = hass.states.get("sensor.zone_1_next_cycle") + + assert minutes_sensor is not None + assert minutes_sensor.state == "unknown" + assert minutes_sensor.attributes["device_class"] == SensorDeviceClass.TIMESTAMP + + # Turn valve on + device.zone1._schedule_enabled = True + + async_fire_time_changed(hass, now + dt_util.dt.timedelta(seconds=10)) + await hass.async_block_till_done() + + # Valve is on, report 10 + next_cycle_sensor = hass.states.get("sensor.zone_1_next_cycle") + + assert next_cycle_sensor is not None + assert next_cycle_sensor.state == next_cycle.isoformat(timespec="seconds") + + async def test_rssi_sensor( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: @@ -104,6 +149,7 @@ async def test_rssi_sensor( rssi = hass.states.get(entity_id) + assert rssi is not None assert ( rssi.attributes["unit_of_measurement"] == SIGNAL_STRENGTH_DECIBELS_MILLIWATT ) diff --git a/tests/components/melnor/test_switch.py b/tests/components/melnor/test_switch.py index 08a361824800..fdd5e8ad33e5 100644 --- a/tests/components/melnor/test_switch.py +++ b/tests/components/melnor/test_switch.py @@ -23,6 +23,8 @@ async def test_manual_watering_switch_metadata(hass: HomeAssistant) -> None: await hass.async_block_till_done() switch = hass.states.get("switch.zone_1") + + assert switch is not None assert switch.attributes["device_class"] == SwitchDeviceClass.SWITCH assert switch.attributes["icon"] == "mdi:sprinkler" @@ -39,6 +41,8 @@ async def test_manual_watering_switch_on_off(hass: HomeAssistant) -> None: await hass.async_block_till_done() switch = hass.states.get("switch.zone_1") + + assert switch is not None assert switch.state is STATE_OFF await hass.services.async_call( @@ -49,6 +53,8 @@ async def test_manual_watering_switch_on_off(hass: HomeAssistant) -> None: ) switch = hass.states.get("switch.zone_1") + + assert switch is not None assert switch.state is STATE_ON assert device.zone1.is_watering is True @@ -60,5 +66,38 @@ async def test_manual_watering_switch_on_off(hass: HomeAssistant) -> None: ) switch = hass.states.get("switch.zone_1") + + assert switch is not None assert switch.state is STATE_OFF assert device.zone1.is_watering is False + + +async def test_schedule_enabled_switch_on_off(hass: HomeAssistant) -> None: + """Test the schedule enabled switch.""" + + entry = mock_config_entry(hass) + + with patch_async_ble_device_from_address(), patch_melnor_device() as device_patch, patch_async_register_callback(): + device = device_patch.return_value + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + switch = hass.states.get("switch.zone_1_schedule") + + assert switch is not None + assert switch.state is STATE_OFF + assert device.zone1.schedule_enabled is False + + await hass.services.async_call( + "switch", + "turn_on", + {"entity_id": "switch.zone_1_schedule"}, + blocking=True, + ) + + switch = hass.states.get("switch.zone_1_schedule") + + assert switch is not None + assert switch.state is STATE_ON + assert device.zone1.schedule_enabled is True From 10aa49be2bba085b383887d52f3956affdc19ef8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 25 May 2023 20:37:18 -0500 Subject: [PATCH 581/822] Speed up async_active_zone by avoiding dict lookups (#93427) Speed up async_active_zone by avoding dict lookups --- homeassistant/components/zone/__init__.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/zone/__init__.py b/homeassistant/components/zone/__init__.py index 2133c8550dab..b7212e910913 100644 --- a/homeassistant/components/zone/__init__.py +++ b/homeassistant/components/zone/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable import logging +from operator import attrgetter from typing import Any, cast from typing_extensions import Self @@ -96,6 +97,8 @@ RELOAD_SERVICE_SCHEMA = vol.Schema({}) STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 +ENTITY_ID_SORTER = attrgetter("entity_id") + @bind_hass def async_active_zone( @@ -106,15 +109,10 @@ def async_active_zone( This method must be run in the event loop. """ # Sort entity IDs so that we are deterministic if equal distance to 2 zones - zones = ( - cast(State, hass.states.get(entity_id)) - for entity_id in sorted(hass.states.async_entity_ids(DOMAIN)) - ) - min_dist = None closest = None - for zone in zones: + for zone in sorted(hass.states.async_all(DOMAIN), key=ENTITY_ID_SORTER): if zone.state == STATE_UNAVAILABLE or zone.attributes.get(ATTR_PASSIVE): continue From d9b43fc43fb6756e57db5c73feafe5d652ae364c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 25 May 2023 20:44:12 -0500 Subject: [PATCH 582/822] Significantly speed up recorder event listener (#93532) * Significantly speed up recorder event listener This code is called every time an event happens since it subscribes to all events. Its our most frequently called listener out of the box. It used to have a seperate filter function but it was later combined after core had some previous refactoring. It was never optimized after that happened. This change reduces the run time by ~70% * decruft --- homeassistant/components/recorder/core.py | 59 ++++++++++++----------- 1 file changed, 31 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 67d3bff3b2af..5023393dc5e2 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -299,9 +299,39 @@ class Recorder(threading.Thread): @callback def async_initialize(self) -> None: """Initialize the recorder.""" + entity_filter = self.entity_filter + exclude_event_types = self.exclude_event_types + queue_put = self._queue.put_nowait + event_task = EventTask + + @callback + def _event_listener(event: Event) -> None: + """Listen for new events and put them in the process queue.""" + if event.event_type in exclude_event_types: + return + + if (entity_id := event.data.get(ATTR_ENTITY_ID)) is None: + queue_put(event_task(event)) + return + + if isinstance(entity_id, str): + if entity_filter(entity_id): + queue_put(event_task(event)) + return + + if isinstance(entity_id, list): + for eid in entity_id: + if entity_filter(eid): + queue_put(event_task(event)) + return + return + + # Unknown what it is. + queue_put(event_task(event)) + self._event_listener = self.hass.bus.async_listen( MATCH_ALL, - self.event_listener, + _event_listener, run_immediately=True, ) self._queue_watcher = async_track_time_interval( @@ -412,27 +442,6 @@ class Recorder(threading.Thread): self._periodic_listener() self._periodic_listener = None - @callback - def _async_event_filter(self, event: Event) -> bool: - """Filter events.""" - if event.event_type in self.exclude_event_types: - return False - - if (entity_id := event.data.get(ATTR_ENTITY_ID)) is None: - return True - - if isinstance(entity_id, str): - return self.entity_filter(entity_id) - - if isinstance(entity_id, list): - for eid in entity_id: - if self.entity_filter(eid): - return True - return False - - # Unknown what it is. - return True - async def _async_close(self, event: Event) -> None: """Empty the queue if its still present at close.""" @@ -1257,12 +1266,6 @@ class Recorder(threading.Thread): _LOGGER.debug("Sending keepalive") self.event_session.connection().scalar(select(1)) - @callback - def event_listener(self, event: Event) -> None: - """Listen for new events and put them in the process queue.""" - if self._async_event_filter(event): - self.queue_task(EventTask(event)) - async def async_block_till_done(self) -> None: """Async version of block_till_done.""" if self._queue.empty() and not self._event_session_has_pending_writes: From 61e57872c3efe2de9a670fdafb1c61c0acfbb0b1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 25 May 2023 20:52:44 -0500 Subject: [PATCH 583/822] Fix not retrying on connection reset during nexia config entry setup (#93576) * Fix not retrying on connection reset during nexia config entry setup fixes ``` 2023-05-26 00:15:39.129 ERROR (MainThread) [homeassistant.config_entries] Error setting up entry Alexander for nexia Traceback (most recent call last): File "/usr/local/lib/python3.11/site-packages/aiohttp/client.py", line 558, in _request resp = await req.send(conn) ^^^^^^^^^^^^^^^^^^^^ File "/usr/local/lib/python3.11/site-packages/aiohttp/client_reqrep.py", line 670, in send await writer.write_headers(status_line, self.headers) File "/usr/local/lib/python3.11/site-packages/aiohttp/http_writer.py", line 130, in write_headers self._write(buf) File "/usr/local/lib/python3.11/site-packages/aiohttp/http_writer.py", line 75, in _write raise ConnectionResetError("Cannot write to closing transport") ConnectionResetError: Cannot write to closing transport The above exception was the direct cause of the following exception: Traceback (most recent call last): File "/usr/src/homeassistant/homeassistant/config_entries.py", line 387, in async_setup result = await component.async_setup_entry(hass, self) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/usr/src/homeassistant/homeassistant/components/nexia/__init__.py", line 47, in async_setup_entry await nexia_home.login() File "/usr/local/lib/python3.11/site-packages/nexia/home.py", line 385, in login request = await self.post_url(self.API_MOBILE_ACCOUNTS_SIGN_IN_URL, payload) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/usr/local/lib/python3.11/site-packages/nexia/home.py", line 157, in post_url response: aiohttp.ClientResponse = await self.session.post( ^^^^^^^^^^^^^^^^^^^^^^^^ File "/usr/local/lib/python3.11/site-packages/aiohttp/client.py", line 572, in _request raise ClientOSError(*exc.args) from exc aiohttp.client_exceptions.ClientOSError: Cannot write to closing transport ``` * coverage --- homeassistant/components/nexia/__init__.py | 4 ++++ tests/components/nexia/test_init.py | 9 +++++++++ tests/components/nexia/util.py | 16 +++++++++++++--- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nexia/__init__.py b/homeassistant/components/nexia/__init__.py index b221f440ff84..0644de58ee7b 100644 --- a/homeassistant/components/nexia/__init__.py +++ b/homeassistant/components/nexia/__init__.py @@ -56,6 +56,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) return False raise ConfigEntryNotReady(f"Error from Nexia service: {http_ex}") from http_ex + except aiohttp.ClientOSError as os_error: + raise ConfigEntryNotReady( + f"Error connecting to Nexia service: {os_error}" + ) from os_error coordinator = NexiaDataUpdateCoordinator(hass, nexia_home) await coordinator.async_config_entry_first_refresh() diff --git a/tests/components/nexia/test_init.py b/tests/components/nexia/test_init.py index dd147c4cb212..5409181f00ec 100644 --- a/tests/components/nexia/test_init.py +++ b/tests/components/nexia/test_init.py @@ -1,5 +1,8 @@ """The init tests for the nexia platform.""" +import aiohttp + from homeassistant.components.nexia.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import EntityRegistry @@ -10,6 +13,12 @@ from .util import async_init_integration from tests.typing import WebSocketGenerator +async def test_setup_retry_client_os_error(hass: HomeAssistant) -> None: + """Verify we retry setup on aiohttp.ClientOSError.""" + config_entry = await async_init_integration(hass, exception=aiohttp.ClientOSError) + assert config_entry.state == ConfigEntryState.SETUP_RETRY + + async def remove_device(ws_client, device_id, config_entry_id): """Remove config entry from a device.""" await ws_client.send_json( diff --git a/tests/components/nexia/util.py b/tests/components/nexia/util.py index d564ccc351cf..318a317fae4f 100644 --- a/tests/components/nexia/util.py +++ b/tests/components/nexia/util.py @@ -15,6 +15,7 @@ from tests.test_util.aiohttp import mock_aiohttp_client async def async_init_integration( hass: HomeAssistant, skip_setup: bool = False, + exception: Exception | None = None, ) -> MockConfigEntry: """Set up the nexia integration in Home Assistant.""" @@ -25,9 +26,18 @@ async def async_init_integration( "nexia.home.load_or_create_uuid", return_value=uuid.uuid4() ): nexia = NexiaHome(mock_session) - mock_session.post( - nexia.API_MOBILE_SESSION_URL, text=load_fixture(session_fixture) - ) + if exception: + + async def _raise_exception(*args, **kwargs): + raise exception + + mock_session.post( + nexia.API_MOBILE_SESSION_URL, side_effect=_raise_exception + ) + else: + mock_session.post( + nexia.API_MOBILE_SESSION_URL, text=load_fixture(session_fixture) + ) mock_session.get( nexia.API_MOBILE_HOUSES_URL.format(house_id=123456), text=load_fixture(house_fixture), From f251c464e2f1b911a80af21eb3119224979ee42b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 25 May 2023 21:18:19 -0500 Subject: [PATCH 584/822] Fix history start time state returning None with some postgresql versions (#93555) * Add additional coverage to history websocket api related issue #93258 * Add additional coverage to history websocket api related issue #93258 * Fix results when union query ends up at the end instead of front * Apply suggestions from code review * resort * zero instead * fix exception * fix tests --- .../components/recorder/history/modern.py | 61 ++++---- .../components/recorder/models/state.py | 11 +- .../components/history/test_websocket_api.py | 143 +++++++++++++----- tests/components/recorder/test_models.py | 14 +- 4 files changed, 147 insertions(+), 82 deletions(-) diff --git a/homeassistant/components/recorder/history/modern.py b/homeassistant/components/recorder/history/modern.py index 5322074c2058..393bcfa3676e 100644 --- a/homeassistant/components/recorder/history/modern.py +++ b/homeassistant/components/recorder/history/modern.py @@ -9,7 +9,6 @@ from typing import Any, cast from sqlalchemy import ( CompoundSelect, - Integer, Select, Subquery, and_, @@ -19,7 +18,6 @@ from sqlalchemy import ( select, union_all, ) -from sqlalchemy.dialects import postgresql from sqlalchemy.engine.row import Row from sqlalchemy.orm.session import Session @@ -52,16 +50,6 @@ _FIELD_MAP = { } -CASTABLE_DOUBLE_TYPE = ( - # MySQL/MariaDB < 10.4+ does not support casting to DOUBLE so we have to use Integer instead but it doesn't - # matter because we don't use the value as its always set to NULL - # - # sqlalchemy.exc.SAWarning: Datatype DOUBLE does not support CAST on MySQL/MariaDb; the CAST will be skipped. - # - Integer().with_variant(postgresql.DOUBLE_PRECISION(), "postgresql") -) - - def _stmt_and_join_attributes( no_attributes: bool, include_last_changed: bool ) -> Select: @@ -79,13 +67,9 @@ def _stmt_and_join_attributes_for_start_state( ) -> Select: """Return the statement and if StateAttributes should be joined.""" _select = select(States.metadata_id, States.state) - _select = _select.add_columns( - literal(value=None).label("last_updated_ts").cast(CASTABLE_DOUBLE_TYPE) - ) + _select = _select.add_columns(literal(value=0).label("last_updated_ts")) if include_last_changed: - _select = _select.add_columns( - literal(value=None).label("last_changed_ts").cast(CASTABLE_DOUBLE_TYPE) - ) + _select = _select.add_columns(literal(value=0).label("last_changed_ts")) if not no_attributes: _select = _select.add_columns(SHARED_ATTR_OR_LEGACY_ATTRIBUTES) return _select @@ -174,28 +158,29 @@ def _significant_states_stmt( stmt = stmt.outerjoin( StateAttributes, States.attributes_id == StateAttributes.attributes_id ) - stmt = stmt.order_by(States.metadata_id, States.last_updated_ts) if not include_start_time_state or not run_start_ts: + stmt = stmt.order_by(States.metadata_id, States.last_updated_ts) return stmt - return _select_from_subquery( - union_all( - _select_from_subquery( - _get_start_time_state_stmt( - run_start_ts, - start_time_ts, - single_metadata_id, - metadata_ids, - no_attributes, - include_last_changed, - ).subquery(), + unioned_subquery = union_all( + _select_from_subquery( + _get_start_time_state_stmt( + run_start_ts, + start_time_ts, + single_metadata_id, + metadata_ids, no_attributes, include_last_changed, - ), - _select_from_subquery(stmt.subquery(), no_attributes, include_last_changed), - ).subquery(), + ).subquery(), + no_attributes, + include_last_changed, + ), + _select_from_subquery(stmt.subquery(), no_attributes, include_last_changed), + ).subquery() + return _select_from_subquery( + unioned_subquery, no_attributes, include_last_changed, - ) + ).order_by(unioned_subquery.c.metadata_id, unioned_subquery.c.last_updated_ts) def get_significant_states_with_session( @@ -279,6 +264,7 @@ def get_significant_states_with_session( entity_id_to_metadata_id, minimal_response, compressed_state_format, + no_attributes=no_attributes, ) @@ -433,6 +419,7 @@ def state_changes_during_period( entity_ids, entity_id_to_metadata_id, descending=descending, + no_attributes=no_attributes, ), ) @@ -528,6 +515,7 @@ def get_last_state_changes( None, entity_ids, entity_id_to_metadata_id, + no_attributes=False, ), ) @@ -651,6 +639,7 @@ def _sorted_states_to_dict( minimal_response: bool = False, compressed_state_format: bool = False, descending: bool = False, + no_attributes: bool = False, ) -> MutableMapping[str, list[State | dict[str, Any]]]: """Convert SQL results into JSON friendly data structure. @@ -665,7 +654,7 @@ def _sorted_states_to_dict( """ field_map = _FIELD_MAP state_class: Callable[ - [Row, dict[str, dict[str, Any]], float | None, str, str, float | None], + [Row, dict[str, dict[str, Any]], float | None, str, str, float | None, bool], State | dict[str, Any], ] if compressed_state_format: @@ -716,6 +705,7 @@ def _sorted_states_to_dict( entity_id, db_state[state_idx], db_state[last_updated_ts_idx], + False, ) for db_state in group ) @@ -738,6 +728,7 @@ def _sorted_states_to_dict( entity_id, prev_state, # type: ignore[arg-type] first_state[last_updated_ts_idx], + no_attributes, ) ) diff --git a/homeassistant/components/recorder/models/state.py b/homeassistant/components/recorder/models/state.py index 523ffdf18527..73e7798b9f5c 100644 --- a/homeassistant/components/recorder/models/state.py +++ b/homeassistant/components/recorder/models/state.py @@ -53,6 +53,7 @@ class LazyState(State): entity_id: str, state: str, last_updated_ts: float | None, + no_attributes: bool, ) -> None: """Init the lazy state.""" self._row = row @@ -143,14 +144,14 @@ def row_to_compressed_state( entity_id: str, state: str, last_updated_ts: float | None, + no_attributes: bool, ) -> dict[str, Any]: """Convert a database row to a compressed state schema 41 and later.""" - comp_state: dict[str, Any] = { - COMPRESSED_STATE_STATE: state, - COMPRESSED_STATE_ATTRIBUTES: decode_attributes_from_source( + comp_state: dict[str, Any] = {COMPRESSED_STATE_STATE: state} + if not no_attributes: + comp_state[COMPRESSED_STATE_ATTRIBUTES] = decode_attributes_from_source( getattr(row, "attributes", None), attr_cache - ), - } + ) row_last_updated_ts: float = last_updated_ts or start_time_ts # type: ignore[assignment] comp_state[COMPRESSED_STATE_LAST_UPDATED] = row_last_updated_ts if ( diff --git a/tests/components/history/test_websocket_api.py b/tests/components/history/test_websocket_api.py index f8d4ec7d9f75..4f00e50def12 100644 --- a/tests/components/history/test_websocket_api.py +++ b/tests/components/history/test_websocket_api.py @@ -1,5 +1,6 @@ """The tests the History component websocket_api.""" # pylint: disable=protected-access,invalid-name +import asyncio from datetime import timedelta from unittest.mock import patch @@ -97,7 +98,7 @@ async def test_history_during_period( assert len(sensor_test_history) == 3 assert sensor_test_history[0]["s"] == "on" - assert sensor_test_history[0]["a"] == {} + assert "a" not in sensor_test_history[0] # no_attributes = True assert isinstance(sensor_test_history[0]["lu"], float) assert "lc" not in sensor_test_history[0] # skipped if the same a last_updated (lu) @@ -510,17 +511,13 @@ async def test_history_stream_historical_only( "start_time": now.timestamp(), "states": { "sensor.four": [ - {"a": {}, "lu": sensor_four_last_updated.timestamp(), "s": "off"} - ], - "sensor.one": [ - {"a": {}, "lu": sensor_one_last_updated.timestamp(), "s": "on"} + {"lu": sensor_four_last_updated.timestamp(), "s": "off"} ], + "sensor.one": [{"lu": sensor_one_last_updated.timestamp(), "s": "on"}], "sensor.three": [ - {"a": {}, "lu": sensor_three_last_updated.timestamp(), "s": "off"} - ], - "sensor.two": [ - {"a": {}, "lu": sensor_two_last_updated.timestamp(), "s": "off"} + {"lu": sensor_three_last_updated.timestamp(), "s": "off"} ], + "sensor.two": [{"lu": sensor_two_last_updated.timestamp(), "s": "off"}], }, }, "id": 1, @@ -857,12 +854,8 @@ async def test_history_stream_live_no_attributes_minimal_response( "end_time": first_end_time, "start_time": now.timestamp(), "states": { - "sensor.one": [ - {"a": {}, "lu": sensor_one_last_updated.timestamp(), "s": "on"} - ], - "sensor.two": [ - {"a": {}, "lu": sensor_two_last_updated.timestamp(), "s": "off"} - ], + "sensor.one": [{"lu": sensor_one_last_updated.timestamp(), "s": "on"}], + "sensor.two": [{"lu": sensor_two_last_updated.timestamp(), "s": "off"}], }, }, "id": 1, @@ -1220,12 +1213,8 @@ async def test_history_stream_live_no_attributes_minimal_response_specific_entit "end_time": first_end_time, "start_time": now.timestamp(), "states": { - "sensor.one": [ - {"a": {}, "lu": sensor_one_last_updated.timestamp(), "s": "on"} - ], - "sensor.two": [ - {"a": {}, "lu": sensor_two_last_updated.timestamp(), "s": "off"} - ], + "sensor.one": [{"lu": sensor_one_last_updated.timestamp(), "s": "on"}], + "sensor.two": [{"lu": sensor_two_last_updated.timestamp(), "s": "off"}], }, }, "id": 1, @@ -1306,12 +1295,8 @@ async def test_history_stream_live_with_future_end_time( "end_time": first_end_time, "start_time": now.timestamp(), "states": { - "sensor.one": [ - {"a": {}, "lu": sensor_one_last_updated.timestamp(), "s": "on"} - ], - "sensor.two": [ - {"a": {}, "lu": sensor_two_last_updated.timestamp(), "s": "off"} - ], + "sensor.one": [{"lu": sensor_one_last_updated.timestamp(), "s": "on"}], + "sensor.two": [{"lu": sensor_two_last_updated.timestamp(), "s": "off"}], }, }, "id": 1, @@ -1505,10 +1490,10 @@ async def test_overflow_queue( "start_time": now.timestamp(), "states": { "sensor.one": [ - {"a": {}, "lu": sensor_one_last_updated.timestamp(), "s": "on"} + {"lu": sensor_one_last_updated.timestamp(), "s": "on"} ], "sensor.two": [ - {"a": {}, "lu": sensor_two_last_updated.timestamp(), "s": "off"} + {"lu": sensor_two_last_updated.timestamp(), "s": "off"} ], }, }, @@ -1722,9 +1707,7 @@ async def test_history_stream_for_invalid_entity_ids( "end_time": sensor_one_last_updated.timestamp(), "start_time": now.timestamp(), "states": { - "sensor.one": [ - {"a": {}, "lu": sensor_one_last_updated.timestamp(), "s": "on"} - ], + "sensor.one": [{"lu": sensor_one_last_updated.timestamp(), "s": "on"}], }, }, "id": 1, @@ -1754,12 +1737,8 @@ async def test_history_stream_for_invalid_entity_ids( "end_time": sensor_two_last_updated.timestamp(), "start_time": now.timestamp(), "states": { - "sensor.one": [ - {"a": {}, "lu": sensor_one_last_updated.timestamp(), "s": "on"} - ], - "sensor.two": [ - {"a": {}, "lu": sensor_two_last_updated.timestamp(), "s": "off"} - ], + "sensor.one": [{"lu": sensor_one_last_updated.timestamp(), "s": "on"}], + "sensor.two": [{"lu": sensor_two_last_updated.timestamp(), "s": "off"}], }, }, "id": 2, @@ -1843,3 +1822,91 @@ async def test_history_stream_for_invalid_entity_ids( "type": "result", "success": False, } + + +async def test_history_stream_historical_only_with_start_time_state_past( + recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test history stream.""" + await async_setup_component( + hass, + "history", + {}, + ) + await async_setup_component(hass, "sensor", {}) + + hass.states.async_set("sensor.one", "first", attributes={"any": "attr"}) + hass.states.get("sensor.one").last_updated + await async_recorder_block_till_done(hass) + + await asyncio.sleep(0.00002) + now = dt_util.utcnow() + await async_recorder_block_till_done(hass) + hass.states.async_set("sensor.one", "second", attributes={"any": "attr"}) + sensor_one_last_updated_second = hass.states.get("sensor.one").last_updated + + await asyncio.sleep(0.00001) + hass.states.async_set("sensor.one", "third", attributes={"any": "attr"}) + sensor_one_last_updated_third = hass.states.get("sensor.one").last_updated + + await async_recorder_block_till_done(hass) + hass.states.async_set("sensor.two", "off", attributes={"any": "attr"}) + sensor_two_last_updated = hass.states.get("sensor.two").last_updated + await async_recorder_block_till_done(hass) + hass.states.async_set("sensor.three", "off", attributes={"any": "changed"}) + sensor_three_last_updated = hass.states.get("sensor.three").last_updated + await async_recorder_block_till_done(hass) + hass.states.async_set("sensor.four", "off", attributes={"any": "again"}) + sensor_four_last_updated = hass.states.get("sensor.four").last_updated + await async_recorder_block_till_done(hass) + hass.states.async_set("switch.excluded", "off", attributes={"any": "again"}) + await async_wait_recording_done(hass) + + end_time = dt_util.utcnow() + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "history/stream", + "entity_ids": ["sensor.one", "sensor.two", "sensor.three", "sensor.four"], + "start_time": now.isoformat(), + "end_time": end_time.isoformat(), + "include_start_time_state": True, + "significant_changes_only": False, + "no_attributes": True, + "minimal_response": True, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["id"] == 1 + assert response["type"] == "result" + + response = await client.receive_json() + + assert response == { + "event": { + "end_time": sensor_four_last_updated.timestamp(), + "start_time": now.timestamp(), + "states": { + "sensor.four": [ + {"lu": sensor_four_last_updated.timestamp(), "s": "off"} + ], + "sensor.one": [ + { + "lu": now.timestamp(), + "s": "first", + }, # should use start time state + {"lu": sensor_one_last_updated_second.timestamp(), "s": "second"}, + {"lu": sensor_one_last_updated_third.timestamp(), "s": "third"}, + ], + "sensor.three": [ + {"lu": sensor_three_last_updated.timestamp(), "s": "off"} + ], + "sensor.two": [{"lu": sensor_two_last_updated.timestamp(), "s": "off"}], + }, + }, + "id": 1, + "type": "event", + } diff --git a/tests/components/recorder/test_models.py b/tests/components/recorder/test_models.py index 8797c153a7b5..d1058c38b705 100644 --- a/tests/components/recorder/test_models.py +++ b/tests/components/recorder/test_models.py @@ -271,7 +271,7 @@ async def test_lazy_state_handles_include_json( entity_id="sensor.invalid", shared_attrs="{INVALID_JSON}", ) - assert LazyState(row, {}, None, row.entity_id, "", 1).attributes == {} + assert LazyState(row, {}, None, row.entity_id, "", 1, False).attributes == {} assert "Error converting row to state attributes" in caplog.text @@ -283,7 +283,9 @@ async def test_lazy_state_can_decode_attributes( entity_id="sensor.invalid", attributes='{"shared":true}', ) - assert LazyState(row, {}, None, row.entity_id, "", 1).attributes == {"shared": True} + assert LazyState(row, {}, None, row.entity_id, "", 1, False).attributes == { + "shared": True + } async def test_lazy_state_handles_different_last_updated_and_last_changed( @@ -298,7 +300,9 @@ async def test_lazy_state_handles_different_last_updated_and_last_changed( last_updated_ts=now.timestamp(), last_changed_ts=(now - timedelta(seconds=60)).timestamp(), ) - lstate = LazyState(row, {}, None, row.entity_id, row.state, row.last_updated_ts) + lstate = LazyState( + row, {}, None, row.entity_id, row.state, row.last_updated_ts, False + ) assert lstate.as_dict() == { "attributes": {"shared": True}, "entity_id": "sensor.valid", @@ -329,7 +333,9 @@ async def test_lazy_state_handles_same_last_updated_and_last_changed( last_updated_ts=now.timestamp(), last_changed_ts=now.timestamp(), ) - lstate = LazyState(row, {}, None, row.entity_id, row.state, row.last_updated_ts) + lstate = LazyState( + row, {}, None, row.entity_id, row.state, row.last_updated_ts, False + ) assert lstate.as_dict() == { "attributes": {"shared": True}, "entity_id": "sensor.valid", From e2b69fc470a76e25b3fd1d34ff2cfc95829d9c71 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 25 May 2023 22:04:38 -0500 Subject: [PATCH 585/822] Significantly improve performance of conversation default_agent listening for new states (#93577) Use the async_track_state_added_domain helper instead of tracking all state changes and rejecting them as it is already optimized for this job --- .../components/conversation/default_agent.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index dccf394ab3fa..f0946010b124 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -31,7 +31,7 @@ from homeassistant.helpers import ( template, translation, ) -from homeassistant.helpers.event import async_track_state_change +from homeassistant.helpers.event import async_track_state_added_domain from homeassistant.util.json import JsonObjectType, json_loads_object from .agent import AbstractConversationAgent, ConversationInput, ConversationResult @@ -83,22 +83,16 @@ def async_setup(hass: core.HomeAssistant) -> None: async_should_expose(hass, DOMAIN, entity_id) @core.callback - def async_entity_state_listener( - changed_entity: str, - old_state: core.State | None, - new_state: core.State | None, - ): + def async_entity_state_listener(event: core.Event) -> None: """Set expose flag on new entities.""" - if old_state is not None or new_state is None: - return - async_should_expose(hass, DOMAIN, changed_entity) + async_should_expose(hass, DOMAIN, event.data["entity_id"]) @core.callback def async_hass_started(hass: core.HomeAssistant) -> None: """Set expose flag on all entities.""" for state in hass.states.async_all(): async_should_expose(hass, DOMAIN, state.entity_id) - async_track_state_change(hass, MATCH_ALL, async_entity_state_listener) + async_track_state_added_domain(hass, MATCH_ALL, async_entity_state_listener) start.async_at_started(hass, async_hass_started) From 48485fc2bf64bd7f850aab7eb6593ac95c67f5e4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 25 May 2023 22:09:13 -0500 Subject: [PATCH 586/822] Complete persistent notifications migration (#92828) * Complete migration of persistent notifications Persistent notifications are no longer stored in the state machine and no longer fire events * Complete migration of persistent notifications Persistent notifications are no longer stored in the state machine and no longer fire events * fixes * fixes * fixes * ws test * update tests * update tests * fix more tests * fix more tests * more fixes * fix * fix person * fix person * keep whitelist * use singleton --- .../persistent_notification/__init__.py | 188 ++++++++++-------- homeassistant/components/person/__init__.py | 6 + tests/common.py | 10 +- tests/components/govee_ble/test_sensor.py | 2 +- tests/components/http/test_ban.py | 8 +- tests/components/hue/test_init.py | 8 +- tests/components/humidifier/test_recorder.py | 2 +- tests/components/netatmo/test_init.py | 14 +- tests/components/notify/test_init.py | 5 +- .../notify/test_persistent_notification.py | 12 +- .../persistent_notification/test_init.py | 108 ++++++---- tests/components/person/conftest.py | 45 +++++ tests/components/person/test_init.py | 41 +--- tests/components/person/test_recorder.py | 4 +- tests/components/safe_mode/test_init.py | 5 +- tests/test_config_entries.py | 42 ++-- tests/test_loader.py | 5 +- 17 files changed, 310 insertions(+), 195 deletions(-) create mode 100644 tests/components/person/conftest.py diff --git a/homeassistant/components/persistent_notification/__init__.py b/homeassistant/components/persistent_notification/__init__.py index 36b496ddde21..042a6acfabb9 100644 --- a/homeassistant/components/persistent_notification/__init__.py +++ b/homeassistant/components/persistent_notification/__init__.py @@ -2,44 +2,67 @@ from __future__ import annotations from collections.abc import Mapping +from datetime import datetime import logging -from typing import Any +from typing import Any, Final, TypedDict import voluptuous as vol +from homeassistant.backports.enum import StrEnum from homeassistant.components import websocket_api -from homeassistant.const import ATTR_FRIENDLY_NAME -from homeassistant.core import Context, HomeAssistant, ServiceCall, callback -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity import async_generate_entity_id +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.helpers import config_validation as cv, singleton +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass -from homeassistant.util import slugify import homeassistant.util.dt as dt_util - -ATTR_CREATED_AT = "created_at" -ATTR_MESSAGE = "message" -ATTR_NOTIFICATION_ID = "notification_id" -ATTR_TITLE = "title" -ATTR_STATUS = "status" +from homeassistant.util.uuid import random_uuid_hex DOMAIN = "persistent_notification" -ENTITY_ID_FORMAT = DOMAIN + ".{}" +ATTR_CREATED_AT: Final = "created_at" +ATTR_MESSAGE: Final = "message" +ATTR_NOTIFICATION_ID: Final = "notification_id" +ATTR_TITLE: Final = "title" +ATTR_STATUS: Final = "status" +STATUS_UNREAD = "unread" +STATUS_READ = "read" + +# Remove EVENT_PERSISTENT_NOTIFICATIONS_UPDATED in Home Assistant 2023.9 EVENT_PERSISTENT_NOTIFICATIONS_UPDATED = "persistent_notifications_updated" + +class Notification(TypedDict): + """Persistent notification.""" + + created_at: datetime + message: str + notification_id: str + title: str | None + status: str + + +class UpdateType(StrEnum): + """Persistent notification update type.""" + + CURRENT = "current" + ADDED = "added" + REMOVED = "removed" + UPDATED = "updated" + + +SIGNAL_PERSISTENT_NOTIFICATIONS_UPDATED = "persistent_notifications_updated" + SCHEMA_SERVICE_NOTIFICATION = vol.Schema( {vol.Required(ATTR_NOTIFICATION_ID): cv.string} ) -DEFAULT_OBJECT_ID = "notification" _LOGGER = logging.getLogger(__name__) -STATE = "notifying" -STATUS_UNREAD = "unread" -STATUS_READ = "read" - @bind_hass def create( @@ -65,31 +88,12 @@ def async_create( message: str, title: str | None = None, notification_id: str | None = None, - *, - context: Context | None = None, ) -> None: """Generate a notification.""" - if (notifications := hass.data.get(DOMAIN)) is None: - notifications = hass.data[DOMAIN] = {} - - if notification_id is not None: - entity_id = ENTITY_ID_FORMAT.format(slugify(notification_id)) - else: - entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, DEFAULT_OBJECT_ID, hass=hass - ) - notification_id = entity_id.split(".")[1] - - attr: dict[str, str] = {ATTR_MESSAGE: message} - if title is not None: - attr[ATTR_TITLE] = title - attr[ATTR_FRIENDLY_NAME] = title - - hass.states.async_set(entity_id, STATE, attr, context=context) - - # Store notification and fire event - # This will eventually replace state machine storage - notifications[entity_id] = { + notifications = _async_get_or_create_notifications(hass) + if notification_id is None: + notification_id = random_uuid_hex() + notifications[notification_id] = { ATTR_MESSAGE: message, ATTR_NOTIFICATION_ID: notification_id, ATTR_STATUS: STATUS_UNREAD, @@ -97,32 +101,39 @@ def async_create( ATTR_CREATED_AT: dt_util.utcnow(), } - hass.bus.async_fire(EVENT_PERSISTENT_NOTIFICATIONS_UPDATED, context=context) + async_dispatcher_send( + hass, + SIGNAL_PERSISTENT_NOTIFICATIONS_UPDATED, + UpdateType.ADDED, + {notification_id: notifications[notification_id]}, + ) + + +@callback +@singleton.singleton(DOMAIN) +def _async_get_or_create_notifications(hass: HomeAssistant) -> dict[str, Notification]: + """Get or create notifications data.""" + return {} @callback @bind_hass -def async_dismiss( - hass: HomeAssistant, notification_id: str, *, context: Context | None = None -) -> None: +def async_dismiss(hass: HomeAssistant, notification_id: str) -> None: """Remove a notification.""" - if (notifications := hass.data.get(DOMAIN)) is None: - notifications = hass.data[DOMAIN] = {} - - entity_id = ENTITY_ID_FORMAT.format(slugify(notification_id)) - - if entity_id not in notifications: + notifications = _async_get_or_create_notifications(hass) + if not (notification := notifications.pop(notification_id, None)): return - - hass.states.async_remove(entity_id, context) - - del notifications[entity_id] - hass.bus.async_fire(EVENT_PERSISTENT_NOTIFICATIONS_UPDATED) + async_dispatcher_send( + hass, + SIGNAL_PERSISTENT_NOTIFICATIONS_UPDATED, + UpdateType.REMOVED, + {notification_id: notification}, + ) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the persistent notification component.""" - notifications = hass.data.setdefault(DOMAIN, {}) + notifications = _async_get_or_create_notifications(hass) @callback def create_service(call: ServiceCall) -> None: @@ -132,21 +143,18 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: call.data[ATTR_MESSAGE], call.data.get(ATTR_TITLE), call.data.get(ATTR_NOTIFICATION_ID), - context=call.context, ) @callback def dismiss_service(call: ServiceCall) -> None: """Handle the dismiss notification service call.""" - async_dismiss(hass, call.data[ATTR_NOTIFICATION_ID], context=call.context) + async_dismiss(hass, call.data[ATTR_NOTIFICATION_ID]) @callback def mark_read_service(call: ServiceCall) -> None: """Handle the mark_read notification service call.""" notification_id = call.data.get(ATTR_NOTIFICATION_ID) - entity_id = ENTITY_ID_FORMAT.format(slugify(notification_id)) - - if entity_id not in notifications: + if notification_id not in notifications: _LOGGER.error( ( "Marking persistent_notification read failed: " @@ -156,9 +164,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) return - notifications[entity_id][ATTR_STATUS] = STATUS_READ - hass.bus.async_fire( - EVENT_PERSISTENT_NOTIFICATIONS_UPDATED, context=call.context + notification = notifications[notification_id] + notification[ATTR_STATUS] = STATUS_READ + async_dispatcher_send( + hass, + SIGNAL_PERSISTENT_NOTIFICATIONS_UPDATED, + UpdateType.UPDATED, + {notification_id: notification}, ) hass.services.async_register( @@ -183,6 +195,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) websocket_api.async_register_command(hass, websocket_get_notifications) + websocket_api.async_register_command(hass, websocket_subscribe_notifications) return True @@ -197,19 +210,36 @@ def websocket_get_notifications( """Return a list of persistent_notifications.""" connection.send_message( websocket_api.result_message( - msg["id"], - [ - { - key: data[key] - for key in ( - ATTR_NOTIFICATION_ID, - ATTR_MESSAGE, - ATTR_STATUS, - ATTR_TITLE, - ATTR_CREATED_AT, - ) - } - for data in hass.data[DOMAIN].values() - ], + msg["id"], list(_async_get_or_create_notifications(hass).values()) ) ) + + +@callback +@websocket_api.websocket_command( + {vol.Required("type"): "persistent_notification/subscribe"} +) +def websocket_subscribe_notifications( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: Mapping[str, Any], +) -> None: + """Return a list of persistent_notifications.""" + notifications = _async_get_or_create_notifications(hass) + msg_id = msg["id"] + + @callback + def _async_send_notification_update( + update_type: UpdateType, notifications: dict[str, Notification] + ) -> None: + connection.send_message( + websocket_api.event_message( + msg["id"], {"type": update_type, "notifications": notifications} + ) + ) + + connection.subscriptions[msg_id] = async_dispatcher_connect( + hass, SIGNAL_PERSISTENT_NOTIFICATIONS_UPDATED, _async_send_notification_update + ) + connection.send_result(msg_id) + _async_send_notification_update(UpdateType.CURRENT, notifications) diff --git a/homeassistant/components/person/__init__.py b/homeassistant/components/person/__init__.py index fe6925b48478..ea325380e111 100644 --- a/homeassistant/components/person/__init__.py +++ b/homeassistant/components/person/__init__.py @@ -47,6 +47,9 @@ 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 @@ -330,6 +333,9 @@ 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( diff --git a/tests/common.py b/tests/common.py index 632294a50fbc..65e119c8c49c 100644 --- a/tests/common.py +++ b/tests/common.py @@ -29,7 +29,7 @@ from homeassistant.auth import ( providers as auth_providers, ) from homeassistant.auth.permissions import system_policies -from homeassistant.components import device_automation +from homeassistant.components import device_automation, persistent_notification as pn from homeassistant.components.device_automation import ( # noqa: F401 _async_get_device_automation_capabilities as async_get_device_automation_capabilities, ) @@ -1396,3 +1396,11 @@ def raise_contains_mocks(val: Any) -> None: if isinstance(val, list): for dict_value in val: raise_contains_mocks(dict_value) + + +@callback +def async_get_persistent_notifications( + hass: HomeAssistant, +) -> dict[str, pn.Notification]: + """Get the current persistent notifications.""" + return pn._async_get_or_create_notifications(hass) diff --git a/tests/components/govee_ble/test_sensor.py b/tests/components/govee_ble/test_sensor.py index e9e66ba73e8d..1408a35142a5 100644 --- a/tests/components/govee_ble/test_sensor.py +++ b/tests/components/govee_ble/test_sensor.py @@ -55,7 +55,7 @@ async def test_gvh5178_error(hass: HomeAssistant) -> None: assert len(hass.states.async_all()) == 0 inject_bluetooth_service_info(hass, GVH5178_SERVICE_INFO_ERROR) await hass.async_block_till_done() - assert len(hass.states.async_all()) == 4 + assert len(hass.states.async_all()) == 3 temp_sensor = hass.states.get("sensor.b51782bc8_remote_temperature") assert temp_sensor.state == STATE_UNAVAILABLE diff --git a/tests/components/http/test_ban.py b/tests/components/http/test_ban.py index b34c8866ff11..e6e237a7b677 100644 --- a/tests/components/http/test_ban.py +++ b/tests/components/http/test_ban.py @@ -25,6 +25,7 @@ from homeassistant.setup import async_setup_component from . import mock_real_ip +from tests.common import async_get_persistent_notifications from tests.typing import ClientSessionGenerator SUPERVISOR_IP = "1.2.3.4" @@ -307,11 +308,10 @@ async def test_ip_bans_file_creation( assert resp.status == HTTPStatus.FORBIDDEN assert m_open.call_count == 1 + notifications = async_get_persistent_notifications(hass) + assert len(notifications) == 2 assert ( - len(notifications := hass.states.async_all("persistent_notification")) == 2 - ) - assert ( - notifications[0].attributes["message"] + notifications["http-login"]["message"] == "Login attempt or request with invalid authentication from example.com (200.201.202.204). See the log for details." ) diff --git a/tests/components/hue/test_init.py b/tests/components/hue/test_init.py index 8a635497237c..bdca6ee135c3 100644 --- a/tests/components/hue/test_init.py +++ b/tests/components/hue/test_init.py @@ -9,7 +9,7 @@ from homeassistant.components import hue from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_get_persistent_notifications @pytest.fixture @@ -162,6 +162,6 @@ async def test_security_vuln_check(hass: HomeAssistant) -> None: await hass.async_block_till_done() - state = hass.states.get("persistent_notification.hue_hub_firmware") - assert state is not None - assert "CVE-2020-6007" in state.attributes["message"] + notifications = async_get_persistent_notifications(hass) + assert "hue_hub_firmware" in notifications + assert "CVE-2020-6007" in notifications["hue_hub_firmware"]["message"] diff --git a/tests/components/humidifier/test_recorder.py b/tests/components/humidifier/test_recorder.py index 4ac765d7f50e..0a38ff050808 100644 --- a/tests/components/humidifier/test_recorder.py +++ b/tests/components/humidifier/test_recorder.py @@ -34,7 +34,7 @@ async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) states = await hass.async_add_executor_job( get_significant_states, hass, now, None, hass.states.async_entity_ids() ) - assert len(states) > 1 + assert len(states) >= 1 for entity_states in states.values(): for state in entity_states: assert ATTR_MIN_HUMIDITY not in state.attributes diff --git a/tests/components/netatmo/test_init.py b/tests/components/netatmo/test_init.py index 2af9aece9e33..1914380521b4 100644 --- a/tests/components/netatmo/test_init.py +++ b/tests/components/netatmo/test_init.py @@ -21,7 +21,11 @@ from .common import ( simulate_webhook, ) -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + async_get_persistent_notifications, +) from tests.components.cloud import mock_cloud # Fake webhook thermostat mode change to "Max" @@ -391,7 +395,10 @@ async def test_setup_component_invalid_token_scope(hass: HomeAssistant) -> None: assert config_entry.state is config_entries.ConfigEntryState.SETUP_ERROR assert hass.config_entries.async_entries(DOMAIN) - assert len(hass.states.async_all()) > 0 + + notifications = async_get_persistent_notifications(hass) + + assert len(notifications) > 0 for config_entry in hass.config_entries.async_entries("netatmo"): await hass.config_entries.async_remove(config_entry.entry_id) @@ -437,7 +444,8 @@ async def test_setup_component_invalid_token(hass: HomeAssistant, config_entry) assert config_entry.state is config_entries.ConfigEntryState.SETUP_ERROR assert hass.config_entries.async_entries(DOMAIN) - assert len(hass.states.async_all()) > 0 + notifications = async_get_persistent_notifications(hass) + assert len(notifications) > 0 for config_entry in hass.config_entries.async_entries("netatmo"): await hass.config_entries.async_remove(config_entry.entry_id) diff --git a/tests/components/notify/test_init.py b/tests/components/notify/test_init.py index dcdfb961d0da..9fb5b9e531a6 100644 --- a/tests/components/notify/test_init.py +++ b/tests/components/notify/test_init.py @@ -14,7 +14,7 @@ from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.reload import async_setup_reload_service from homeassistant.setup import async_setup_component -from tests.common import MockPlatform, mock_platform +from tests.common import MockPlatform, async_get_persistent_notifications, mock_platform class MockNotifyPlatform(MockPlatform): @@ -139,7 +139,8 @@ async def test_warn_template( ) # We should only log it once assert caplog.text.count("Passing templates to notify service is deprecated") == 1 - assert hass.states.get("persistent_notification.notification") is not None + notifications = async_get_persistent_notifications(hass) + assert len(notifications) == 1 async def test_invalid_platform( diff --git a/tests/components/notify/test_persistent_notification.py b/tests/components/notify/test_persistent_notification.py index 32634e354e9c..6f273ac3d9bd 100644 --- a/tests/components/notify/test_persistent_notification.py +++ b/tests/components/notify/test_persistent_notification.py @@ -4,6 +4,8 @@ import homeassistant.components.persistent_notification as pn from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from tests.common import async_get_persistent_notifications + async def test_async_send_message(hass: HomeAssistant) -> None: """Test sending a message to notify.persistent_notification service.""" @@ -17,9 +19,9 @@ async def test_async_send_message(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - entity_ids = hass.states.async_entity_ids(pn.DOMAIN) - assert len(entity_ids) == 1 + notifications = async_get_persistent_notifications(hass) + assert len(notifications) == 1 + notification = notifications[list(notifications)[0]] - state = hass.states.get(entity_ids[0]) - assert state.attributes.get("message") == "Hello" - assert state.attributes.get("title") == "Test notification" + assert notification["message"] == "Hello" + assert notification["title"] == "Test notification" diff --git a/tests/components/persistent_notification/test_init.py b/tests/components/persistent_notification/test_init.py index de0ae25497a8..7fd8b00d3e1b 100644 --- a/tests/components/persistent_notification/test_init.py +++ b/tests/components/persistent_notification/test_init.py @@ -6,7 +6,6 @@ from homeassistant.components.websocket_api.const import TYPE_RESULT from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import async_capture_events from tests.typing import WebSocketGenerator @@ -18,22 +17,14 @@ async def setup_integration(hass): async def test_create(hass: HomeAssistant) -> None: """Test creating notification without title or notification id.""" - notifications = hass.data[pn.DOMAIN] + notifications = pn._async_get_or_create_notifications(hass) assert len(hass.states.async_entity_ids(pn.DOMAIN)) == 0 assert len(notifications) == 0 pn.async_create(hass, "Hello World 2", title="2 beers") - - entity_ids = hass.states.async_entity_ids(pn.DOMAIN) - assert len(entity_ids) == 1 assert len(notifications) == 1 - state = hass.states.get(entity_ids[0]) - assert state.state == pn.STATE - assert state.attributes.get("message") == "Hello World 2" - assert state.attributes.get("title") == "2 beers" - - notification = notifications.get(entity_ids[0]) + notification = notifications[list(notifications)[0]] assert notification["status"] == pn.STATUS_UNREAD assert notification["message"] == "Hello World 2" assert notification["title"] == "2 beers" @@ -42,54 +33,42 @@ async def test_create(hass: HomeAssistant) -> None: async def test_create_notification_id(hass: HomeAssistant) -> None: """Ensure overwrites existing notification with same id.""" - notifications = hass.data[pn.DOMAIN] + notifications = pn._async_get_or_create_notifications(hass) assert len(hass.states.async_entity_ids(pn.DOMAIN)) == 0 assert len(notifications) == 0 pn.async_create(hass, "test", notification_id="Beer 2") - assert len(hass.states.async_entity_ids()) == 1 assert len(notifications) == 1 + notification = notifications[list(notifications)[0]] - entity_id = "persistent_notification.beer_2" - state = hass.states.get(entity_id) - assert state.attributes.get("message") == "test" - - notification = notifications.get(entity_id) assert notification["message"] == "test" assert notification["title"] is None pn.async_create(hass, "test 2", notification_id="Beer 2") # We should have overwritten old one - assert len(hass.states.async_entity_ids()) == 1 - state = hass.states.get(entity_id) - assert state.attributes.get("message") == "test 2" + notification = notifications[list(notifications)[0]] - notification = notifications.get(entity_id) assert notification["message"] == "test 2" async def test_dismiss_notification(hass: HomeAssistant) -> None: """Ensure removal of specific notification.""" - notifications = hass.data[pn.DOMAIN] - assert len(hass.states.async_entity_ids(pn.DOMAIN)) == 0 + notifications = pn._async_get_or_create_notifications(hass) assert len(notifications) == 0 pn.async_create(hass, "test", notification_id="Beer 2") - assert len(hass.states.async_entity_ids(pn.DOMAIN)) == 1 assert len(notifications) == 1 pn.async_dismiss(hass, notification_id="Beer 2") - assert len(hass.states.async_entity_ids(pn.DOMAIN)) == 0 assert len(notifications) == 0 async def test_mark_read(hass: HomeAssistant) -> None: """Ensure notification is marked as Read.""" - events = async_capture_events(hass, pn.EVENT_PERSISTENT_NOTIFICATIONS_UPDATED) - notifications = hass.data[pn.DOMAIN] + notifications = pn._async_get_or_create_notifications(hass) assert len(notifications) == 0 await hass.services.async_call( @@ -99,20 +78,17 @@ async def test_mark_read(hass: HomeAssistant) -> None: blocking=True, ) - entity_id = "persistent_notification.beer_2" assert len(notifications) == 1 - notification = notifications.get(entity_id) + notification = notifications[list(notifications)[0]] assert notification["status"] == pn.STATUS_UNREAD - assert len(events) == 1 await hass.services.async_call( pn.DOMAIN, "mark_read", {"notification_id": "Beer 2"}, blocking=True ) assert len(notifications) == 1 - notification = notifications.get(entity_id) + notification = notifications[list(notifications)[0]] assert notification["status"] == pn.STATUS_READ - assert len(events) == 2 await hass.services.async_call( pn.DOMAIN, @@ -121,7 +97,6 @@ async def test_mark_read(hass: HomeAssistant) -> None: blocking=True, ) assert len(notifications) == 0 - assert len(events) == 3 async def test_ws_get_notifications( @@ -172,3 +147,68 @@ async def test_ws_get_notifications( msg = await client.receive_json() notifications = msg["result"] assert len(notifications) == 0 + + +async def test_ws_get_subscribe( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test websocket subscribe endpoint for retrieving persistent notifications.""" + await async_setup_component(hass, pn.DOMAIN, {}) + + client = await hass_ws_client(hass) + + await client.send_json({"id": 5, "type": "persistent_notification/subscribe"}) + msg = await client.receive_json() + assert msg["id"] == 5 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + + msg = await client.receive_json() + assert msg["id"] == 5 + assert msg["type"] == "event" + assert msg["event"] + event = msg["event"] + assert event["type"] == "current" + assert event["notifications"] == {} + + # Create + pn.async_create(hass, "test", notification_id="Beer 2") + + msg = await client.receive_json() + assert msg["id"] == 5 + assert msg["type"] == "event" + assert msg["event"] + event = msg["event"] + assert event["type"] == "added" + notifications = event["notifications"] + assert len(notifications) == 1 + notification = notifications[list(notifications)[0]] + assert notification["notification_id"] == "Beer 2" + assert notification["message"] == "test" + assert notification["title"] is None + assert notification["status"] == pn.STATUS_UNREAD + assert notification["created_at"] is not None + + # Mark Read + await hass.services.async_call( + pn.DOMAIN, "mark_read", {"notification_id": "Beer 2"} + ) + msg = await client.receive_json() + assert msg["id"] == 5 + assert msg["type"] == "event" + assert msg["event"] + event = msg["event"] + assert event["type"] == "updated" + notifications = event["notifications"] + assert len(notifications) == 1 + notification = notifications[list(notifications)[0]] + assert notification["status"] == pn.STATUS_READ + + # Dismiss + pn.async_dismiss(hass, "Beer 2") + msg = await client.receive_json() + assert msg["id"] == 5 + assert msg["type"] == "event" + assert msg["event"] + event = msg["event"] + assert event["type"] == "removed" diff --git a/tests/components/person/conftest.py b/tests/components/person/conftest.py new file mode 100644 index 000000000000..4079ff242677 --- /dev/null +++ b/tests/components/person/conftest.py @@ -0,0 +1,45 @@ +"""The tests for the person component.""" +import logging + +import pytest + +from homeassistant.components import person +from homeassistant.components.person import DOMAIN +from homeassistant.helpers import collection +from homeassistant.setup import async_setup_component + +DEVICE_TRACKER = "device_tracker.test_tracker" +DEVICE_TRACKER_2 = "device_tracker.test_tracker_2" + + +@pytest.fixture +def storage_collection(hass): + """Return an empty storage collection.""" + id_manager = collection.IDManager() + return person.PersonStorageCollection( + person.PersonStore(hass, person.STORAGE_VERSION, person.STORAGE_KEY), + id_manager, + collection.YamlCollection( + logging.getLogger(f"{person.__name__}.yaml_collection"), id_manager + ), + ) + + +@pytest.fixture +def storage_setup(hass, hass_storage, hass_admin_user): + """Storage setup.""" + hass_storage[DOMAIN] = { + "key": DOMAIN, + "version": 1, + "data": { + "persons": [ + { + "id": "1234", + "name": "tracked person", + "user_id": hass_admin_user.id, + "device_trackers": [DEVICE_TRACKER], + } + ] + }, + } + assert hass.loop.run_until_complete(async_setup_component(hass, DOMAIN, {})) diff --git a/tests/components/person/test_init.py b/tests/components/person/test_init.py index 433c9529e780..71491ee3cafd 100644 --- a/tests/components/person/test_init.py +++ b/tests/components/person/test_init.py @@ -1,5 +1,4 @@ """The tests for the person component.""" -import logging from typing import Any from unittest.mock import patch @@ -24,48 +23,14 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import Context, CoreState, HomeAssistant, State -from homeassistant.helpers import collection, entity_registry as er +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component +from .conftest import DEVICE_TRACKER, DEVICE_TRACKER_2 + from tests.common import MockUser, mock_component, mock_restore_cache from tests.typing import WebSocketGenerator -DEVICE_TRACKER = "device_tracker.test_tracker" -DEVICE_TRACKER_2 = "device_tracker.test_tracker_2" - - -@pytest.fixture -def storage_collection(hass): - """Return an empty storage collection.""" - id_manager = collection.IDManager() - return person.PersonStorageCollection( - person.PersonStore(hass, person.STORAGE_VERSION, person.STORAGE_KEY), - id_manager, - collection.YamlCollection( - logging.getLogger(f"{person.__name__}.yaml_collection"), id_manager - ), - ) - - -@pytest.fixture -def storage_setup(hass, hass_storage, hass_admin_user): - """Storage setup.""" - hass_storage[DOMAIN] = { - "key": DOMAIN, - "version": 1, - "data": { - "persons": [ - { - "id": "1234", - "name": "tracked person", - "user_id": hass_admin_user.id, - "device_trackers": [DEVICE_TRACKER], - } - ] - }, - } - assert hass.loop.run_until_complete(async_setup_component(hass, DOMAIN, {})) - async def test_minimal_setup(hass: HomeAssistant) -> None: """Test minimal config with only name.""" diff --git a/tests/components/person/test_recorder.py b/tests/components/person/test_recorder.py index 879db2ec11f3..51b5691bd8e1 100644 --- a/tests/components/person/test_recorder.py +++ b/tests/components/person/test_recorder.py @@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from tests.common import async_fire_time_changed +from tests.common import MockUser, async_fire_time_changed from tests.components.recorder.common import async_wait_recording_done @@ -18,6 +18,8 @@ async def test_exclude_attributes( recorder_mock: Recorder, hass: HomeAssistant, enable_custom_integrations: None, + hass_admin_user: MockUser, + storage_setup, ) -> None: """Test update attributes to be excluded.""" now = dt_util.utcnow() diff --git a/tests/components/safe_mode/test_init.py b/tests/components/safe_mode/test_init.py index 043c65d7e43f..82f5f5180dad 100644 --- a/tests/components/safe_mode/test_init.py +++ b/tests/components/safe_mode/test_init.py @@ -2,9 +2,12 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from tests.common import async_get_persistent_notifications + async def test_works(hass: HomeAssistant) -> None: """Test safe mode works.""" assert await async_setup_component(hass, "safe_mode", {}) await hass.async_block_till_done() - assert len(hass.states.async_entity_ids()) == 1 + notifications = async_get_persistent_notifications(hass) + assert len(notifications) == 1 diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 53602ec28ff6..774c85dbc0d4 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -46,6 +46,8 @@ from .common import ( mock_integration, ) +from tests.common import async_get_persistent_notifications + @pytest.fixture(autouse=True) def mock_handlers() -> Generator[None, None, None]: @@ -733,14 +735,16 @@ async def test_discovery_notification(hass: HomeAssistant) -> None: title="Test Title", data={"token": "abcd"} ) + notifications = async_get_persistent_notifications(hass) + assert "config_entry_discovery" not in notifications + # Start first discovery flow to assert that reconfigure notification fires flow1 = await hass.config_entries.flow.async_init( "test", context={"source": config_entries.SOURCE_DISCOVERY} ) - await hass.async_block_till_done() - state = hass.states.get("persistent_notification.config_entry_discovery") - assert state is not None + notifications = async_get_persistent_notifications(hass) + assert "config_entry_discovery" in notifications # Start a second discovery flow so we can finish the first and assert that # the discovery notification persists until the second one is complete @@ -752,15 +756,15 @@ async def test_discovery_notification(hass: HomeAssistant) -> None: assert flow1["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY await hass.async_block_till_done() - state = hass.states.get("persistent_notification.config_entry_discovery") - assert state is not None + notifications = async_get_persistent_notifications(hass) + assert "config_entry_discovery" in notifications flow2 = await hass.config_entries.flow.async_configure(flow2["flow_id"], {}) assert flow2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY await hass.async_block_till_done() - state = hass.states.get("persistent_notification.config_entry_discovery") - assert state is None + notifications = async_get_persistent_notifications(hass) + assert "config_entry_discovery" not in notifications async def test_reauth_notification(hass: HomeAssistant) -> None: @@ -797,8 +801,8 @@ async def test_reauth_notification(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - state = hass.states.get("persistent_notification.config_entry_reconfigure") - assert state is None + notifications = async_get_persistent_notifications(hass) + assert "config_entry_reconfigure" not in notifications # Start first reauth flow to assert that reconfigure notification fires flow1 = await hass.config_entries.flow.async_init( @@ -806,8 +810,8 @@ async def test_reauth_notification(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - state = hass.states.get("persistent_notification.config_entry_reconfigure") - assert state is not None + notifications = async_get_persistent_notifications(hass) + assert "config_entry_reconfigure" in notifications # Start a second reauth flow so we can finish the first and assert that # the reconfigure notification persists until the second one is complete @@ -819,15 +823,15 @@ async def test_reauth_notification(hass: HomeAssistant) -> None: assert flow1["type"] == data_entry_flow.FlowResultType.ABORT await hass.async_block_till_done() - state = hass.states.get("persistent_notification.config_entry_reconfigure") - assert state is not None + notifications = async_get_persistent_notifications(hass) + assert "config_entry_reconfigure" in notifications flow2 = await hass.config_entries.flow.async_configure(flow2["flow_id"], {}) assert flow2["type"] == data_entry_flow.FlowResultType.ABORT await hass.async_block_till_done() - state = hass.states.get("persistent_notification.config_entry_reconfigure") - assert state is None + notifications = async_get_persistent_notifications(hass) + assert "config_entry_reconfigure" not in notifications async def test_discovery_notification_not_created(hass: HomeAssistant) -> None: @@ -2461,8 +2465,8 @@ async def test_partial_flows_hidden( assert len(hass.config_entries.flow.async_progress()) == 0 await hass.async_block_till_done() - state = hass.states.get("persistent_notification.config_entry_discovery") - assert state is None + notifications = async_get_persistent_notifications(hass) + assert "config_entry_discovery" not in notifications # Let the flow init complete pause_discovery.set() @@ -2474,8 +2478,8 @@ async def test_partial_flows_hidden( assert len(hass.config_entries.flow.async_progress()) == 1 await hass.async_block_till_done() - state = hass.states.get("persistent_notification.config_entry_discovery") - assert state is not None + notifications = async_get_persistent_notifications(hass) + assert "config_entry_discovery" in notifications async def test_async_setup_init_entry(hass: HomeAssistant) -> None: diff --git a/tests/test_loader.py b/tests/test_loader.py index a591ccbb204e..6e62be08f66a 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -8,7 +8,7 @@ from homeassistant.components import http, hue from homeassistant.components.hue import light as hue_light from homeassistant.core import HomeAssistant, callback -from .common import MockModule, mock_integration +from .common import MockModule, async_get_persistent_notifications, mock_integration async def test_component_dependencies(hass: HomeAssistant) -> None: @@ -61,7 +61,8 @@ async def test_component_wrapper(hass: HomeAssistant) -> None: components = loader.Components(hass) components.persistent_notification.async_create("message") - assert len(hass.states.async_entity_ids("persistent_notification")) == 1 + notifications = async_get_persistent_notifications(hass) + assert len(notifications) async def test_helpers_wrapper(hass: HomeAssistant) -> None: From 88893121a10440b82918ec1546946a90225a0dcf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 25 May 2023 22:38:44 -0500 Subject: [PATCH 587/822] Increase timeout for test_purge_many_old_events (#93579) --- tests/components/recorder/test_purge.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/components/recorder/test_purge.py b/tests/components/recorder/test_purge.py index fadcc688753c..096108e03492 100644 --- a/tests/components/recorder/test_purge.py +++ b/tests/components/recorder/test_purge.py @@ -1599,6 +1599,7 @@ def _add_state_with_state_attributes( ) +@pytest.mark.timeout(30) async def test_purge_many_old_events( async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant ) -> None: From 9e53bca45212e3db8c4117f16471c468ea863c9c Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 26 May 2023 08:09:29 +0200 Subject: [PATCH 588/822] Add entity name translations to Rituals Perfume Genie (#92549) --- .../rituals_perfume_genie/binary_sensor.py | 11 +------ .../rituals_perfume_genie/entity.py | 2 ++ .../rituals_perfume_genie/number.py | 11 +------ .../rituals_perfume_genie/select.py | 3 +- .../rituals_perfume_genie/sensor.py | 17 +++------- .../rituals_perfume_genie/strings.json | 31 +++++++++++++++++++ .../rituals_perfume_genie/switch.py | 1 - .../test_binary_sensor.py | 4 +-- .../rituals_perfume_genie/test_sensor.py | 4 +-- 9 files changed, 44 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/rituals_perfume_genie/binary_sensor.py b/homeassistant/components/rituals_perfume_genie/binary_sensor.py index 3c49dc14f516..19732169b86a 100644 --- a/homeassistant/components/rituals_perfume_genie/binary_sensor.py +++ b/homeassistant/components/rituals_perfume_genie/binary_sensor.py @@ -39,7 +39,7 @@ class RitualsBinarySensorEntityDescription( ENTITY_DESCRIPTIONS = ( RitualsBinarySensorEntityDescription( key="charging", - name="Battery Charging", + translation_key="charging", device_class=BinarySensorDeviceClass.BATTERY_CHARGING, entity_category=EntityCategory.DIAGNOSTIC, is_on_fn=lambda diffuser: diffuser.charging, @@ -71,15 +71,6 @@ class RitualsBinarySensorEntity(DiffuserEntity, BinarySensorEntity): entity_description: RitualsBinarySensorEntityDescription - def __init__( - self, - coordinator: RitualsDataUpdateCoordinator, - description: RitualsBinarySensorEntityDescription, - ) -> None: - """Initialize Rituals binary sensor entity.""" - super().__init__(coordinator, description) - self._attr_name = f"{coordinator.diffuser.name} {description.name}" - @property def is_on(self) -> bool: """Return the state of the binary sensor.""" diff --git a/homeassistant/components/rituals_perfume_genie/entity.py b/homeassistant/components/rituals_perfume_genie/entity.py index e5b9f3ebd6fe..713c3905f05c 100644 --- a/homeassistant/components/rituals_perfume_genie/entity.py +++ b/homeassistant/components/rituals_perfume_genie/entity.py @@ -15,6 +15,8 @@ MODEL2 = "The Perfume Genie 2.0" class DiffuserEntity(CoordinatorEntity[RitualsDataUpdateCoordinator]): """Representation of a diffuser entity.""" + _attr_has_entity_name = True + def __init__( self, coordinator: RitualsDataUpdateCoordinator, diff --git a/homeassistant/components/rituals_perfume_genie/number.py b/homeassistant/components/rituals_perfume_genie/number.py index 8049e53fa0d4..3e6af33315f6 100644 --- a/homeassistant/components/rituals_perfume_genie/number.py +++ b/homeassistant/components/rituals_perfume_genie/number.py @@ -35,7 +35,7 @@ class RitualsNumberEntityDescription( ENTITY_DESCRIPTIONS = ( RitualsNumberEntityDescription( key="perfume_amount", - name="Perfume Amount", + translation_key="perfume_amount", icon="mdi:gauge", native_min_value=1, native_max_value=3, @@ -66,15 +66,6 @@ class RitualsNumberEntity(DiffuserEntity, NumberEntity): entity_description: RitualsNumberEntityDescription - def __init__( - self, - coordinator: RitualsDataUpdateCoordinator, - description: RitualsNumberEntityDescription, - ) -> None: - """Initialize the diffuser perfume amount number.""" - super().__init__(coordinator, description) - self._attr_name = f"{coordinator.diffuser.name} {description.name}" - @property def native_value(self) -> int: """Return the number value.""" diff --git a/homeassistant/components/rituals_perfume_genie/select.py b/homeassistant/components/rituals_perfume_genie/select.py index 10ad5dbf3bbb..42e18624d13b 100644 --- a/homeassistant/components/rituals_perfume_genie/select.py +++ b/homeassistant/components/rituals_perfume_genie/select.py @@ -35,7 +35,7 @@ class RitualsSelectEntityDescription( ENTITY_DESCRIPTIONS = ( RitualsSelectEntityDescription( key="room_size_square_meter", - name="Room Size", + translation_key="room_size_square_meter", icon="mdi:ruler-square", unit_of_measurement=AREA_SQUARE_METERS, entity_category=EntityCategory.CONFIG, @@ -80,7 +80,6 @@ class RitualsSelectEntity(DiffuserEntity, SelectEntity): self._attr_entity_registry_enabled_default = ( self.coordinator.diffuser.has_battery ) - self._attr_name = f"{coordinator.diffuser.name} {description.name}" @property def current_option(self) -> str: diff --git a/homeassistant/components/rituals_perfume_genie/sensor.py b/homeassistant/components/rituals_perfume_genie/sensor.py index e48bf4de2193..944efb215366 100644 --- a/homeassistant/components/rituals_perfume_genie/sensor.py +++ b/homeassistant/components/rituals_perfume_genie/sensor.py @@ -40,7 +40,7 @@ class RitualsSensorEntityDescription( ENTITY_DESCRIPTIONS = ( RitualsSensorEntityDescription( key="battery_percentage", - name="Battery", + translation_key="battery_percentage", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, value_fn=lambda diffuser: diffuser.battery_percentage, @@ -48,19 +48,19 @@ ENTITY_DESCRIPTIONS = ( ), RitualsSensorEntityDescription( key="fill", - name="Fill", + translation_key="fill", icon="mdi:beaker", value_fn=lambda diffuser: diffuser.fill, ), RitualsSensorEntityDescription( key="perfume", - name="Perfume", + translation_key="perfume", icon="mdi:tag", value_fn=lambda diffuser: diffuser.perfume, ), RitualsSensorEntityDescription( key="wifi_percentage", - name="Wifi", + translation_key="wifi_percentage", icon="mdi:wifi", native_unit_of_measurement=PERCENTAGE, value_fn=lambda diffuser: diffuser.wifi_percentage, @@ -92,15 +92,6 @@ class RitualsSensorEntity(DiffuserEntity, SensorEntity): entity_description: RitualsSensorEntityDescription _attr_entity_category = EntityCategory.DIAGNOSTIC - def __init__( - self, - coordinator: RitualsDataUpdateCoordinator, - description: RitualsSensorEntityDescription, - ) -> None: - """Initialize the diffuser sensor.""" - super().__init__(coordinator, description) - self._attr_name = f"{coordinator.diffuser.name} {description.name}" - @property def native_value(self) -> str | int: """Return the sensor value.""" diff --git a/homeassistant/components/rituals_perfume_genie/strings.json b/homeassistant/components/rituals_perfume_genie/strings.json index 8824923c3138..f4570dd4cfc3 100644 --- a/homeassistant/components/rituals_perfume_genie/strings.json +++ b/homeassistant/components/rituals_perfume_genie/strings.json @@ -17,5 +17,36 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "binary_sensor": { + "charging": { + "name": "[%key:component::binary_sensor::entity_component::battery_charging::name%]" + } + }, + "number": { + "perfume_amount": { + "name": "Perfume amount" + } + }, + "select": { + "room_size_square_meter": { + "name": "Room size" + } + }, + "sensor": { + "battery_percentage": { + "name": "[%key:component::sensor::entity_component::battery::name%]" + }, + "fill": { + "name": "Fill" + }, + "perfume": { + "name": "Perfume" + }, + "wifi_percentage": { + "name": "Wi-Fi signal" + } + } } } diff --git a/homeassistant/components/rituals_perfume_genie/switch.py b/homeassistant/components/rituals_perfume_genie/switch.py index 44ff951f5326..a6083e514302 100644 --- a/homeassistant/components/rituals_perfume_genie/switch.py +++ b/homeassistant/components/rituals_perfume_genie/switch.py @@ -73,7 +73,6 @@ class RitualsSwitchEntity(DiffuserEntity, SwitchEntity): ) -> None: """Initialize the diffuser switch.""" super().__init__(coordinator, description) - self._attr_name = coordinator.diffuser.name self._attr_is_on = description.is_on_fn(coordinator.diffuser) async def async_turn_on(self, **kwargs: Any) -> None: diff --git a/tests/components/rituals_perfume_genie/test_binary_sensor.py b/tests/components/rituals_perfume_genie/test_binary_sensor.py index 082725a8f997..dae654d6e16c 100644 --- a/tests/components/rituals_perfume_genie/test_binary_sensor.py +++ b/tests/components/rituals_perfume_genie/test_binary_sensor.py @@ -20,14 +20,14 @@ async def test_binary_sensors( await init_integration(hass, config_entry, [diffuser]) hublot = diffuser.hublot - state = hass.states.get("binary_sensor.genie_battery_charging") + state = hass.states.get("binary_sensor.genie_charging") assert state assert state.state == STATE_ON assert ( state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.BATTERY_CHARGING ) - entry = entity_registry.async_get("binary_sensor.genie_battery_charging") + entry = entity_registry.async_get("binary_sensor.genie_charging") assert entry assert entry.unique_id == f"{hublot}-charging" assert entry.entity_category == EntityCategory.DIAGNOSTIC diff --git a/tests/components/rituals_perfume_genie/test_sensor.py b/tests/components/rituals_perfume_genie/test_sensor.py index af6d501d36ad..eb4211f1a200 100644 --- a/tests/components/rituals_perfume_genie/test_sensor.py +++ b/tests/components/rituals_perfume_genie/test_sensor.py @@ -55,13 +55,13 @@ async def test_sensors_diffuser_v1_battery_cartridge( assert entry.unique_id == f"{hublot}-battery_percentage" assert entry.entity_category == EntityCategory.DIAGNOSTIC - state = hass.states.get("sensor.genie_wifi") + state = hass.states.get("sensor.genie_wi_fi_signal") assert state assert state.state == str(diffuser.wifi_percentage) assert state.attributes.get(ATTR_DEVICE_CLASS) is None assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE - entry = entity_registry.async_get("sensor.genie_wifi") + entry = entity_registry.async_get("sensor.genie_wi_fi_signal") assert entry assert entry.unique_id == f"{hublot}-wifi_percentage" assert entry.entity_category == EntityCategory.DIAGNOSTIC From ec98e223306f80dc663a9daee8c90064fbe419d7 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 26 May 2023 08:13:13 +0200 Subject: [PATCH 589/822] Add type hints for stub_blueprint_populate fixture (#93568) Add typehints for stub_blueprint_populate fixture --- pylint/plugins/hass_enforce_type_hints.py | 1 + tests/components/alarm_control_panel/test_device_action.py | 2 +- tests/components/alarm_control_panel/test_device_condition.py | 2 +- tests/components/alarm_control_panel/test_device_trigger.py | 2 +- tests/components/arcam_fmj/test_device_trigger.py | 2 +- tests/components/automation/conftest.py | 2 +- tests/components/binary_sensor/test_device_condition.py | 2 +- tests/components/binary_sensor/test_device_trigger.py | 2 +- tests/components/blueprint/common.py | 4 +++- tests/components/blueprint/conftest.py | 2 +- tests/components/climate/test_device_action.py | 2 +- tests/components/climate/test_device_condition.py | 2 +- tests/components/climate/test_device_trigger.py | 2 +- tests/components/config/test_automation.py | 2 +- tests/components/config/test_device_registry.py | 2 +- tests/components/config/test_script.py | 2 +- tests/components/conftest.py | 2 +- tests/components/cover/test_device_action.py | 2 +- tests/components/cover/test_device_condition.py | 2 +- tests/components/cover/test_device_trigger.py | 2 +- tests/components/deconz/test_device_trigger.py | 2 +- tests/components/default_config/test_init.py | 2 +- tests/components/demo/conftest.py | 2 +- tests/components/device_automation/test_init.py | 2 +- tests/components/device_automation/test_toggle_entity.py | 2 +- tests/components/device_tracker/test_device_condition.py | 2 +- tests/components/device_tracker/test_device_trigger.py | 2 +- tests/components/emulated_hue/conftest.py | 2 +- tests/components/fan/test_device_action.py | 2 +- tests/components/fan/test_device_condition.py | 2 +- tests/components/fan/test_device_trigger.py | 2 +- tests/components/geo_location/test_trigger.py | 2 +- tests/components/homeassistant/triggers/conftest.py | 2 +- tests/components/homekit_controller/test_device_trigger.py | 2 +- tests/components/humidifier/test_device_action.py | 2 +- tests/components/humidifier/test_device_condition.py | 2 +- tests/components/humidifier/test_device_trigger.py | 2 +- tests/components/kodi/test_device_trigger.py | 2 +- tests/components/light/test_device_action.py | 2 +- tests/components/light/test_device_condition.py | 2 +- tests/components/light/test_device_trigger.py | 2 +- tests/components/litejet/test_trigger.py | 2 +- tests/components/lock/test_device_action.py | 2 +- tests/components/lock/test_device_condition.py | 2 +- tests/components/lock/test_device_trigger.py | 2 +- tests/components/logbook/conftest.py | 2 +- tests/components/media_player/test_device_condition.py | 2 +- tests/components/media_player/test_device_trigger.py | 2 +- tests/components/mqtt/test_device_trigger.py | 2 +- tests/components/mqtt/test_trigger.py | 2 +- tests/components/number/test_device_action.py | 2 +- tests/components/philips_js/test_device_trigger.py | 2 +- tests/components/remote/test_device_action.py | 2 +- tests/components/remote/test_device_condition.py | 2 +- tests/components/remote/test_device_trigger.py | 2 +- tests/components/script/conftest.py | 2 +- tests/components/search/test_init.py | 2 +- tests/components/sensor/test_device_condition.py | 2 +- tests/components/sensor/test_device_trigger.py | 2 +- tests/components/sun/test_trigger.py | 2 +- tests/components/switch/test_device_action.py | 2 +- tests/components/switch/test_device_condition.py | 2 +- tests/components/switch/test_device_trigger.py | 2 +- tests/components/tag/test_trigger.py | 2 +- tests/components/tasmota/test_device_trigger.py | 2 +- tests/components/text/test_device_action.py | 2 +- tests/components/trace/conftest.py | 2 +- tests/components/update/test_device_trigger.py | 2 +- tests/components/vacuum/test_device_action.py | 2 +- tests/components/vacuum/test_device_condition.py | 2 +- tests/components/vacuum/test_device_trigger.py | 2 +- tests/components/water_heater/test_device_action.py | 2 +- tests/components/webhook/test_trigger.py | 2 +- tests/components/zha/test_device_action.py | 2 +- tests/components/zha/test_device_trigger.py | 2 +- tests/components/zone/test_trigger.py | 2 +- 76 files changed, 78 insertions(+), 75 deletions(-) diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index dc9682173516..1d7bac65c19b 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -140,6 +140,7 @@ _TEST_FIXTURES: dict[str, list[str] | str] = { "recorder_mock": "Recorder", "requests_mock": "requests_mock.Mocker", "snapshot": "SnapshotAssertion", + "stub_blueprint_populate": "None", "tmp_path": "Path", "tmpdir": "py.path.local", } diff --git a/tests/components/alarm_control_panel/test_device_action.py b/tests/components/alarm_control_panel/test_device_action.py index 5c9f6622905d..c587d94f3ed7 100644 --- a/tests/components/alarm_control_panel/test_device_action.py +++ b/tests/components/alarm_control_panel/test_device_action.py @@ -31,7 +31,7 @@ from tests.common import ( @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/alarm_control_panel/test_device_condition.py b/tests/components/alarm_control_panel/test_device_condition.py index d56ddccab64f..b1687a385b39 100644 --- a/tests/components/alarm_control_panel/test_device_condition.py +++ b/tests/components/alarm_control_panel/test_device_condition.py @@ -30,7 +30,7 @@ from tests.common import ( @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/alarm_control_panel/test_device_trigger.py b/tests/components/alarm_control_panel/test_device_trigger.py index 0cdcf1d88506..d81c83702d8a 100644 --- a/tests/components/alarm_control_panel/test_device_trigger.py +++ b/tests/components/alarm_control_panel/test_device_trigger.py @@ -35,7 +35,7 @@ from tests.common import ( @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/arcam_fmj/test_device_trigger.py b/tests/components/arcam_fmj/test_device_trigger.py index 48561f251b37..012bc3a20af6 100644 --- a/tests/components/arcam_fmj/test_device_trigger.py +++ b/tests/components/arcam_fmj/test_device_trigger.py @@ -16,7 +16,7 @@ from tests.common import ( @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/automation/conftest.py b/tests/components/automation/conftest.py index 1aa56bbf9b94..1d2d39a463d6 100644 --- a/tests/components/automation/conftest.py +++ b/tests/components/automation/conftest.py @@ -4,5 +4,5 @@ import pytest @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/binary_sensor/test_device_condition.py b/tests/components/binary_sensor/test_device_condition.py index 2e1f77aa091e..b1a0296d0d35 100644 --- a/tests/components/binary_sensor/test_device_condition.py +++ b/tests/components/binary_sensor/test_device_condition.py @@ -24,7 +24,7 @@ from tests.common import ( @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/binary_sensor/test_device_trigger.py b/tests/components/binary_sensor/test_device_trigger.py index 7f2cf7a5ba57..49ae9e017caa 100644 --- a/tests/components/binary_sensor/test_device_trigger.py +++ b/tests/components/binary_sensor/test_device_trigger.py @@ -24,7 +24,7 @@ from tests.common import ( @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/blueprint/common.py b/tests/components/blueprint/common.py index 28dad51d4f47..f1ccf63b26a6 100644 --- a/tests/components/blueprint/common.py +++ b/tests/components/blueprint/common.py @@ -1,9 +1,11 @@ """Blueprints test helpers.""" +from collections.abc import Generator +from typing import Any from unittest.mock import patch -def stub_blueprint_populate_fixture_helper(): +def stub_blueprint_populate_fixture_helper() -> Generator[None, Any, None]: """Stub copying the blueprints to the config folder.""" with patch( "homeassistant.components.blueprint.models.DomainBlueprints.async_populate" diff --git a/tests/components/blueprint/conftest.py b/tests/components/blueprint/conftest.py index 2f54fb6092e3..21cc17ef1ba6 100644 --- a/tests/components/blueprint/conftest.py +++ b/tests/components/blueprint/conftest.py @@ -4,5 +4,5 @@ import pytest @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/climate/test_device_action.py b/tests/components/climate/test_device_action.py index afbbccf70ecc..30fe9c92dc61 100644 --- a/tests/components/climate/test_device_action.py +++ b/tests/components/climate/test_device_action.py @@ -24,7 +24,7 @@ from tests.common import ( @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/climate/test_device_condition.py b/tests/components/climate/test_device_condition.py index 7d90e92101c7..2feb8073f07c 100644 --- a/tests/components/climate/test_device_condition.py +++ b/tests/components/climate/test_device_condition.py @@ -24,7 +24,7 @@ from tests.common import ( @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/climate/test_device_trigger.py b/tests/components/climate/test_device_trigger.py index 14d21b41e019..0cfb0a896b35 100644 --- a/tests/components/climate/test_device_trigger.py +++ b/tests/components/climate/test_device_trigger.py @@ -30,7 +30,7 @@ from tests.common import ( @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/config/test_automation.py b/tests/components/config/test_automation.py index 5122c7ec7497..ac6780c09724 100644 --- a/tests/components/config/test_automation.py +++ b/tests/components/config/test_automation.py @@ -14,7 +14,7 @@ from tests.typing import ClientSessionGenerator @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/config/test_device_registry.py b/tests/components/config/test_device_registry.py index aa08a705f46d..25b465192cf4 100644 --- a/tests/components/config/test_device_registry.py +++ b/tests/components/config/test_device_registry.py @@ -11,7 +11,7 @@ from tests.typing import WebSocketGenerator @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/config/test_script.py b/tests/components/config/test_script.py index 934d7ef7b895..fd1aed1b7508 100644 --- a/tests/components/config/test_script.py +++ b/tests/components/config/test_script.py @@ -14,7 +14,7 @@ from tests.typing import ClientSessionGenerator @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 1deb857c3156..c985565b1bea 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -37,7 +37,7 @@ def entity_registry_enabled_by_default() -> Generator[None, None, None]: # Blueprint test fixtures @pytest.fixture(name="stub_blueprint_populate") -def stub_blueprint_populate_fixture(): +def stub_blueprint_populate_fixture() -> Generator[None, Any, None]: """Stub copying the blueprints to the config folder.""" from tests.components.blueprint.common import stub_blueprint_populate_fixture_helper diff --git a/tests/components/cover/test_device_action.py b/tests/components/cover/test_device_action.py index a67c84a50596..17f6e2185e92 100644 --- a/tests/components/cover/test_device_action.py +++ b/tests/components/cover/test_device_action.py @@ -20,7 +20,7 @@ from tests.common import ( @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/cover/test_device_condition.py b/tests/components/cover/test_device_condition.py index 1eb3aa22a06b..f1afe6c6d6bd 100644 --- a/tests/components/cover/test_device_condition.py +++ b/tests/components/cover/test_device_condition.py @@ -28,7 +28,7 @@ from tests.common import ( @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/cover/test_device_trigger.py b/tests/components/cover/test_device_trigger.py index ef391707a9b2..6d70acd7f011 100644 --- a/tests/components/cover/test_device_trigger.py +++ b/tests/components/cover/test_device_trigger.py @@ -31,7 +31,7 @@ from tests.common import ( @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/deconz/test_device_trigger.py b/tests/components/deconz/test_device_trigger.py index 2d87358ebb0b..e26a22b02e45 100644 --- a/tests/components/deconz/test_device_trigger.py +++ b/tests/components/deconz/test_device_trigger.py @@ -40,7 +40,7 @@ from tests.test_util.aiohttp import AiohttpClientMocker @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/default_config/test_init.py b/tests/components/default_config/test_init.py index 846cb3c40f62..f3907aac5480 100644 --- a/tests/components/default_config/test_init.py +++ b/tests/components/default_config/test_init.py @@ -9,7 +9,7 @@ from homeassistant.setup import async_setup_component @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/demo/conftest.py b/tests/components/demo/conftest.py index 6edcf4959183..6cfd1a33f9cc 100644 --- a/tests/components/demo/conftest.py +++ b/tests/components/demo/conftest.py @@ -8,7 +8,7 @@ from tests.components.light.conftest import mock_light_profiles # noqa: F401 @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/device_automation/test_init.py b/tests/components/device_automation/test_init.py index 8b082f26de4e..d48fb520eba3 100644 --- a/tests/components/device_automation/test_init.py +++ b/tests/components/device_automation/test_init.py @@ -32,7 +32,7 @@ from tests.typing import WebSocketGenerator @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/device_automation/test_toggle_entity.py b/tests/components/device_automation/test_toggle_entity.py index 8f12a3fc5d20..f02704cdc135 100644 --- a/tests/components/device_automation/test_toggle_entity.py +++ b/tests/components/device_automation/test_toggle_entity.py @@ -13,7 +13,7 @@ from tests.common import async_fire_time_changed, async_mock_service @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/device_tracker/test_device_condition.py b/tests/components/device_tracker/test_device_condition.py index 2089722389d4..4ef22f77ca31 100644 --- a/tests/components/device_tracker/test_device_condition.py +++ b/tests/components/device_tracker/test_device_condition.py @@ -19,7 +19,7 @@ from tests.common import ( @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/device_tracker/test_device_trigger.py b/tests/components/device_tracker/test_device_trigger.py index 517295774bf7..677e5e1d5477 100644 --- a/tests/components/device_tracker/test_device_trigger.py +++ b/tests/components/device_tracker/test_device_trigger.py @@ -25,7 +25,7 @@ from tests.common import ( @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/emulated_hue/conftest.py b/tests/components/emulated_hue/conftest.py index 652ca6536e30..e25a2099227f 100644 --- a/tests/components/emulated_hue/conftest.py +++ b/tests/components/emulated_hue/conftest.py @@ -4,5 +4,5 @@ import pytest @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/fan/test_device_action.py b/tests/components/fan/test_device_action.py index 195eb932d7a5..80d3dfd2b50b 100644 --- a/tests/components/fan/test_device_action.py +++ b/tests/components/fan/test_device_action.py @@ -19,7 +19,7 @@ from tests.common import ( @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/fan/test_device_condition.py b/tests/components/fan/test_device_condition.py index 516cdea1e6a0..acb206741b3e 100644 --- a/tests/components/fan/test_device_condition.py +++ b/tests/components/fan/test_device_condition.py @@ -19,7 +19,7 @@ from tests.common import ( @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/fan/test_device_trigger.py b/tests/components/fan/test_device_trigger.py index cb2b557dcadf..61c49e7e6eaa 100644 --- a/tests/components/fan/test_device_trigger.py +++ b/tests/components/fan/test_device_trigger.py @@ -24,7 +24,7 @@ from tests.common import ( @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/geo_location/test_trigger.py b/tests/components/geo_location/test_trigger.py index 8fcbc9d2f55c..a5e0f99c5c2c 100644 --- a/tests/components/geo_location/test_trigger.py +++ b/tests/components/geo_location/test_trigger.py @@ -17,7 +17,7 @@ from tests.common import async_mock_service, mock_component @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/homeassistant/triggers/conftest.py b/tests/components/homeassistant/triggers/conftest.py index 9f53537819b7..9dabbad99c9d 100644 --- a/tests/components/homeassistant/triggers/conftest.py +++ b/tests/components/homeassistant/triggers/conftest.py @@ -4,5 +4,5 @@ import pytest @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/homekit_controller/test_device_trigger.py b/tests/components/homekit_controller/test_device_trigger.py index 580f46fb821e..d98c07a1728d 100644 --- a/tests/components/homekit_controller/test_device_trigger.py +++ b/tests/components/homekit_controller/test_device_trigger.py @@ -21,7 +21,7 @@ from tests.common import ( @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/humidifier/test_device_action.py b/tests/components/humidifier/test_device_action.py index 1bcb09d45837..3312c2634583 100644 --- a/tests/components/humidifier/test_device_action.py +++ b/tests/components/humidifier/test_device_action.py @@ -24,7 +24,7 @@ from tests.common import ( @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/humidifier/test_device_condition.py b/tests/components/humidifier/test_device_condition.py index 5671cce70e06..dcd4245e401c 100644 --- a/tests/components/humidifier/test_device_condition.py +++ b/tests/components/humidifier/test_device_condition.py @@ -24,7 +24,7 @@ from tests.common import ( @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/humidifier/test_device_trigger.py b/tests/components/humidifier/test_device_trigger.py index 72d1e8c93327..e6e0d4bdb4d0 100644 --- a/tests/components/humidifier/test_device_trigger.py +++ b/tests/components/humidifier/test_device_trigger.py @@ -34,7 +34,7 @@ from tests.common import ( @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/kodi/test_device_trigger.py b/tests/components/kodi/test_device_trigger.py index 140a77839f9a..59d8a5148cf0 100644 --- a/tests/components/kodi/test_device_trigger.py +++ b/tests/components/kodi/test_device_trigger.py @@ -19,7 +19,7 @@ from tests.common import ( @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/light/test_device_action.py b/tests/components/light/test_device_action.py index c37827f8f17c..ef1d653fbd9a 100644 --- a/tests/components/light/test_device_action.py +++ b/tests/components/light/test_device_action.py @@ -27,7 +27,7 @@ from tests.common import ( @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/light/test_device_condition.py b/tests/components/light/test_device_condition.py index 5496f93d4b37..42790cc91cbf 100644 --- a/tests/components/light/test_device_condition.py +++ b/tests/components/light/test_device_condition.py @@ -24,7 +24,7 @@ from tests.common import ( @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/light/test_device_trigger.py b/tests/components/light/test_device_trigger.py index d65c70407614..18f3555aaf78 100644 --- a/tests/components/light/test_device_trigger.py +++ b/tests/components/light/test_device_trigger.py @@ -24,7 +24,7 @@ from tests.common import ( @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/litejet/test_trigger.py b/tests/components/litejet/test_trigger.py index d8042ea456ca..e3d7caad65e3 100644 --- a/tests/components/litejet/test_trigger.py +++ b/tests/components/litejet/test_trigger.py @@ -17,7 +17,7 @@ from tests.common import async_fire_time_changed_exact, async_mock_service @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/lock/test_device_action.py b/tests/components/lock/test_device_action.py index 56c6f82c6a87..682467bf4754 100644 --- a/tests/components/lock/test_device_action.py +++ b/tests/components/lock/test_device_action.py @@ -19,7 +19,7 @@ from tests.common import ( @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/lock/test_device_condition.py b/tests/components/lock/test_device_condition.py index 9440636eebd0..32341d15e7b9 100644 --- a/tests/components/lock/test_device_condition.py +++ b/tests/components/lock/test_device_condition.py @@ -26,7 +26,7 @@ from tests.common import ( @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/lock/test_device_trigger.py b/tests/components/lock/test_device_trigger.py index a761352492dd..13340fbe6682 100644 --- a/tests/components/lock/test_device_trigger.py +++ b/tests/components/lock/test_device_trigger.py @@ -31,7 +31,7 @@ from tests.common import ( @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/logbook/conftest.py b/tests/components/logbook/conftest.py index e2626047d99e..8795ba3c018a 100644 --- a/tests/components/logbook/conftest.py +++ b/tests/components/logbook/conftest.py @@ -4,5 +4,5 @@ import pytest @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/media_player/test_device_condition.py b/tests/components/media_player/test_device_condition.py index 2bb8d52c75d6..d70a9a90893f 100644 --- a/tests/components/media_player/test_device_condition.py +++ b/tests/components/media_player/test_device_condition.py @@ -27,7 +27,7 @@ from tests.common import ( @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/media_player/test_device_trigger.py b/tests/components/media_player/test_device_trigger.py index 1a95fb87aba5..d8d91c3495bf 100644 --- a/tests/components/media_player/test_device_trigger.py +++ b/tests/components/media_player/test_device_trigger.py @@ -32,7 +32,7 @@ from tests.common import ( @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/mqtt/test_device_trigger.py b/tests/components/mqtt/test_device_trigger.py index e37e7c6cb870..9954b0f9ba46 100644 --- a/tests/components/mqtt/test_device_trigger.py +++ b/tests/components/mqtt/test_device_trigger.py @@ -26,7 +26,7 @@ from tests.typing import MqttMockHAClient, MqttMockHAClientGenerator, WebSocketG @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/mqtt/test_trigger.py b/tests/components/mqtt/test_trigger.py index 675acf7750de..97ded1f22947 100644 --- a/tests/components/mqtt/test_trigger.py +++ b/tests/components/mqtt/test_trigger.py @@ -12,7 +12,7 @@ from tests.common import async_fire_mqtt_message, async_mock_service, mock_compo @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/number/test_device_action.py b/tests/components/number/test_device_action.py index 7d6e4a8520c2..9cd8d3d4943e 100644 --- a/tests/components/number/test_device_action.py +++ b/tests/components/number/test_device_action.py @@ -24,7 +24,7 @@ from tests.common import ( @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/philips_js/test_device_trigger.py b/tests/components/philips_js/test_device_trigger.py index ae492ade3ece..339b30d63555 100644 --- a/tests/components/philips_js/test_device_trigger.py +++ b/tests/components/philips_js/test_device_trigger.py @@ -15,7 +15,7 @@ from tests.common import ( @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/remote/test_device_action.py b/tests/components/remote/test_device_action.py index 13c6da5f778e..d652f4d869d0 100644 --- a/tests/components/remote/test_device_action.py +++ b/tests/components/remote/test_device_action.py @@ -19,7 +19,7 @@ from tests.common import ( @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/remote/test_device_condition.py b/tests/components/remote/test_device_condition.py index ba6cc9fe4f46..a0899daf0afa 100644 --- a/tests/components/remote/test_device_condition.py +++ b/tests/components/remote/test_device_condition.py @@ -24,7 +24,7 @@ from tests.common import ( @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/remote/test_device_trigger.py b/tests/components/remote/test_device_trigger.py index 2f39486a0fee..fdd7b9e73ed1 100644 --- a/tests/components/remote/test_device_trigger.py +++ b/tests/components/remote/test_device_trigger.py @@ -24,7 +24,7 @@ from tests.common import ( @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/script/conftest.py b/tests/components/script/conftest.py index e2626047d99e..8795ba3c018a 100644 --- a/tests/components/script/conftest.py +++ b/tests/components/script/conftest.py @@ -4,5 +4,5 @@ import pytest @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/search/test_init.py b/tests/components/search/test_init.py index e6f7d656f733..4ee6f46c2f86 100644 --- a/tests/components/search/test_init.py +++ b/tests/components/search/test_init.py @@ -16,7 +16,7 @@ from tests.typing import WebSocketGenerator @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/sensor/test_device_condition.py b/tests/components/sensor/test_device_condition.py index 90479927d57c..dcd7e16a514e 100644 --- a/tests/components/sensor/test_device_condition.py +++ b/tests/components/sensor/test_device_condition.py @@ -30,7 +30,7 @@ from tests.testing_config.custom_components.test.sensor import UNITS_OF_MEASUREM @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/sensor/test_device_trigger.py b/tests/components/sensor/test_device_trigger.py index e360117987c0..010c947e7ab3 100644 --- a/tests/components/sensor/test_device_trigger.py +++ b/tests/components/sensor/test_device_trigger.py @@ -34,7 +34,7 @@ from tests.testing_config.custom_components.test.sensor import UNITS_OF_MEASUREM @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/sun/test_trigger.py b/tests/components/sun/test_trigger.py index 988e069ac5ff..9d8f5d82a511 100644 --- a/tests/components/sun/test_trigger.py +++ b/tests/components/sun/test_trigger.py @@ -22,7 +22,7 @@ from tests.common import async_fire_time_changed, async_mock_service, mock_compo @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/switch/test_device_action.py b/tests/components/switch/test_device_action.py index fbdd2716fcad..623629e4b91c 100644 --- a/tests/components/switch/test_device_action.py +++ b/tests/components/switch/test_device_action.py @@ -19,7 +19,7 @@ from tests.common import ( @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/switch/test_device_condition.py b/tests/components/switch/test_device_condition.py index 08e4487750d6..22f5a562dafa 100644 --- a/tests/components/switch/test_device_condition.py +++ b/tests/components/switch/test_device_condition.py @@ -24,7 +24,7 @@ from tests.common import ( @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/switch/test_device_trigger.py b/tests/components/switch/test_device_trigger.py index c971a9011c95..58ffa7e7c189 100644 --- a/tests/components/switch/test_device_trigger.py +++ b/tests/components/switch/test_device_trigger.py @@ -24,7 +24,7 @@ from tests.common import ( @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/tag/test_trigger.py b/tests/components/tag/test_trigger.py index 12c072b8f2b8..2780b9280275 100644 --- a/tests/components/tag/test_trigger.py +++ b/tests/components/tag/test_trigger.py @@ -12,7 +12,7 @@ from tests.common import async_mock_service @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/tasmota/test_device_trigger.py b/tests/components/tasmota/test_device_trigger.py index 05e31daa441d..fe3240790dde 100644 --- a/tests/components/tasmota/test_device_trigger.py +++ b/tests/components/tasmota/test_device_trigger.py @@ -26,7 +26,7 @@ from tests.typing import MqttMockHAClient, WebSocketGenerator @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/text/test_device_action.py b/tests/components/text/test_device_action.py index bcc35cd66d4a..523a39c56409 100644 --- a/tests/components/text/test_device_action.py +++ b/tests/components/text/test_device_action.py @@ -24,7 +24,7 @@ from tests.common import ( @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/trace/conftest.py b/tests/components/trace/conftest.py index d5f8dece98f3..01dc57a99482 100644 --- a/tests/components/trace/conftest.py +++ b/tests/components/trace/conftest.py @@ -4,5 +4,5 @@ import pytest @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/update/test_device_trigger.py b/tests/components/update/test_device_trigger.py index 768d3efede10..bddcefa07c5b 100644 --- a/tests/components/update/test_device_trigger.py +++ b/tests/components/update/test_device_trigger.py @@ -23,7 +23,7 @@ from tests.common import ( @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/vacuum/test_device_action.py b/tests/components/vacuum/test_device_action.py index b4bef23e9389..643d9ad0130e 100644 --- a/tests/components/vacuum/test_device_action.py +++ b/tests/components/vacuum/test_device_action.py @@ -19,7 +19,7 @@ from tests.common import ( @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/vacuum/test_device_condition.py b/tests/components/vacuum/test_device_condition.py index ec73ac441b73..b1ed65d689f7 100644 --- a/tests/components/vacuum/test_device_condition.py +++ b/tests/components/vacuum/test_device_condition.py @@ -24,7 +24,7 @@ from tests.common import ( @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/vacuum/test_device_trigger.py b/tests/components/vacuum/test_device_trigger.py index 62ced757c038..84519a805458 100644 --- a/tests/components/vacuum/test_device_trigger.py +++ b/tests/components/vacuum/test_device_trigger.py @@ -24,7 +24,7 @@ from tests.common import ( @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/water_heater/test_device_action.py b/tests/components/water_heater/test_device_action.py index 65b04b33351e..b1e12dcab947 100644 --- a/tests/components/water_heater/test_device_action.py +++ b/tests/components/water_heater/test_device_action.py @@ -19,7 +19,7 @@ from tests.common import ( @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/webhook/test_trigger.py b/tests/components/webhook/test_trigger.py index 2848a3fb759f..327fc428cd06 100644 --- a/tests/components/webhook/test_trigger.py +++ b/tests/components/webhook/test_trigger.py @@ -11,7 +11,7 @@ from tests.typing import ClientSessionGenerator @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/zha/test_device_action.py b/tests/components/zha/test_device_action.py index 6566a3093356..f1ab44f69eb2 100644 --- a/tests/components/zha/test_device_action.py +++ b/tests/components/zha/test_device_action.py @@ -27,7 +27,7 @@ from tests.common import ( @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/zha/test_device_trigger.py b/tests/components/zha/test_device_trigger.py index 3538ba44c556..85e012c5bfb2 100644 --- a/tests/components/zha/test_device_trigger.py +++ b/tests/components/zha/test_device_trigger.py @@ -27,7 +27,7 @@ from tests.common import ( @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/zone/test_trigger.py b/tests/components/zone/test_trigger.py index c359136ac08c..7f44cecefe1c 100644 --- a/tests/components/zone/test_trigger.py +++ b/tests/components/zone/test_trigger.py @@ -11,7 +11,7 @@ from tests.common import async_mock_service, mock_component @pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" From d852ba8b09b8713f523e653e19061187b2971b57 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 26 May 2023 08:13:45 +0200 Subject: [PATCH 590/822] Remove redundant stub_blueprint_populate fixture for MQTT integration (#93570) Remove redundant stub_blueprint_populate fixture --- tests/components/mqtt/conftest.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/tests/components/mqtt/conftest.py b/tests/components/mqtt/conftest.py index 84861356e08b..ebe86c1f1dfb 100644 --- a/tests/components/mqtt/conftest.py +++ b/tests/components/mqtt/conftest.py @@ -5,11 +5,6 @@ import pytest from tests.components.light.conftest import mock_light_profiles # noqa: F401 -@pytest.fixture(autouse=True, name="stub_blueprint_populate") -def stub_blueprint_populate_autouse(stub_blueprint_populate): - """Stub copying the blueprints to the config folder.""" - - @pytest.fixture(autouse=True) def patch_hass_config(mock_hass_config: None) -> None: """Patch configuration.yaml.""" From 3633062024cbbd90b1b6e689e3061dd7b0f91ee2 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 26 May 2023 08:24:08 +0200 Subject: [PATCH 591/822] Remove platform yaml Frontier Silicon (#93552) --- .../frontier_silicon/config_flow.py | 38 +----- .../frontier_silicon/media_player.py | 52 +------- .../components/frontier_silicon/strings.json | 6 - .../frontier_silicon/test_config_flow.py | 118 +----------------- 4 files changed, 4 insertions(+), 210 deletions(-) diff --git a/homeassistant/components/frontier_silicon/config_flow.py b/homeassistant/components/frontier_silicon/config_flow.py index 5179b02bbc3c..2274b1cdb449 100644 --- a/homeassistant/components/frontier_silicon/config_flow.py +++ b/homeassistant/components/frontier_silicon/config_flow.py @@ -16,7 +16,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import ssdp -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.data_entry_flow import FlowResult from .const import ( @@ -61,42 +61,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): _webfsapi_url: str _reauth_entry: config_entries.ConfigEntry | None = None # Only used in reauth flows - async def async_step_import(self, import_info: dict[str, Any]) -> FlowResult: - """Handle the import of legacy configuration.yaml entries.""" - - device_url = f"http://{import_info[CONF_HOST]}:{import_info[CONF_PORT]}/device" - try: - webfsapi_url = await AFSAPI.get_webfsapi_endpoint(device_url) - except FSConnectionError: - return self.async_abort(reason="cannot_connect") - except Exception as exception: # pylint: disable=broad-except - _LOGGER.exception(exception) - return self.async_abort(reason="unknown") - - afsapi = AFSAPI(webfsapi_url, import_info[CONF_PIN]) - try: - unique_id = await afsapi.get_radio_id() - except NotImplementedException: - unique_id = None # Not all radios have this call implemented - except FSConnectionError: - return self.async_abort(reason="cannot_connect") - except InvalidPinException: - return self.async_abort(reason="invalid_auth") - except Exception as exception: # pylint: disable=broad-except - _LOGGER.exception(exception) - return self.async_abort(reason="unknown") - - await self.async_set_unique_id(unique_id, raise_on_progress=False) - self._abort_if_unique_id_configured() - - return self.async_create_entry( - title=import_info[CONF_NAME] or "Radio", - data={ - CONF_WEBFSAPI_URL: webfsapi_url, - CONF_PIN: import_info[CONF_PIN], - }, - ) - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: diff --git a/homeassistant/components/frontier_silicon/media_player.py b/homeassistant/components/frontier_silicon/media_player.py index 54c17429b56e..04b689ae917c 100644 --- a/homeassistant/components/frontier_silicon/media_player.py +++ b/homeassistant/components/frontier_silicon/media_player.py @@ -10,10 +10,8 @@ from afsapi import ( NotImplementedException as FSNotImplementedException, PlayState, ) -import voluptuous as vol from homeassistant.components.media_player import ( - PLATFORM_SCHEMA, BrowseError, BrowseMedia, MediaPlayerEntity, @@ -21,62 +19,16 @@ from homeassistant.components.media_player import ( MediaPlayerState, MediaType, ) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .browse_media import browse_node, browse_top_level -from .const import CONF_PIN, DEFAULT_PIN, DEFAULT_PORT, DOMAIN, MEDIA_CONTENT_ID_PRESET +from .const import DOMAIN, MEDIA_CONTENT_ID_PRESET _LOGGER = logging.getLogger(__name__) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_PASSWORD, default=DEFAULT_PIN): cv.string, - vol.Optional(CONF_NAME): cv.string, - } -) - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Frontier Silicon platform. - - YAML is deprecated, and imported automatically. - """ - - ir.async_create_issue( - hass, - DOMAIN, - "remove_yaml", - breaks_in_ha_version="2023.6.0", - is_fixable=False, - severity=ir.IssueSeverity.WARNING, - translation_key="removed_yaml", - ) - - await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={ - CONF_NAME: config.get(CONF_NAME), - CONF_HOST: config.get(CONF_HOST), - CONF_PORT: config.get(CONF_PORT, DEFAULT_PORT), - CONF_PIN: config.get(CONF_PASSWORD, DEFAULT_PIN), - }, - ) - async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/frontier_silicon/strings.json b/homeassistant/components/frontier_silicon/strings.json index 193ca7123f45..a10c3f535a10 100644 --- a/homeassistant/components/frontier_silicon/strings.json +++ b/homeassistant/components/frontier_silicon/strings.json @@ -30,11 +30,5 @@ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]" } - }, - "issues": { - "removed_yaml": { - "title": "The Frontier Silicon YAML configuration has been removed", - "description": "Configuring Frontier Silicon using YAML has been removed.\n\nYour existing YAML configuration is not used by Home Assistant.\n\nRemove the YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } } } diff --git a/tests/components/frontier_silicon/test_config_flow.py b/tests/components/frontier_silicon/test_config_flow.py index c7ff99d877c0..bedac792f026 100644 --- a/tests/components/frontier_silicon/test_config_flow.py +++ b/tests/components/frontier_silicon/test_config_flow.py @@ -11,7 +11,7 @@ from homeassistant.components.frontier_silicon.const import ( DEFAULT_PIN, DOMAIN, ) -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PIN, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -37,122 +37,6 @@ INVALID_MOCK_DISCOVERY = ssdp.SsdpServiceInfo( ) -@pytest.mark.parametrize( - ("radio_id_return_value", "radio_id_side_effect"), - [("mock_radio_id", None), (None, NotImplementedException)], -) -async def test_import_success( - hass: HomeAssistant, - radio_id_return_value: str | None, - radio_id_side_effect: Exception | None, -) -> None: - """Test successful import.""" - with patch( - "homeassistant.components.frontier_silicon.config_flow.AFSAPI.get_radio_id", - return_value=radio_id_return_value, - side_effect=radio_id_side_effect, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_HOST: "1.1.1.1", - CONF_PORT: 80, - CONF_PIN: "1234", - CONF_NAME: "Test name", - }, - ) - - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "Test name" - assert result["data"] == { - CONF_WEBFSAPI_URL: "http://1.1.1.1:80/webfsapi", - CONF_PIN: "1234", - } - - -@pytest.mark.parametrize( - ("webfsapi_endpoint_error", "result_reason"), - [ - (ConnectionError, "cannot_connect"), - (ValueError, "unknown"), - ], -) -async def test_import_webfsapi_endpoint_failures( - hass: HomeAssistant, webfsapi_endpoint_error: Exception, result_reason: str -) -> None: - """Test various failure of get_webfsapi_endpoint.""" - with patch( - "homeassistant.components.frontier_silicon.config_flow.AFSAPI.get_webfsapi_endpoint", - side_effect=webfsapi_endpoint_error, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_HOST: "1.1.1.1", - CONF_PORT: 80, - CONF_PIN: "1234", - CONF_NAME: "Test name", - }, - ) - - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == result_reason - - -@pytest.mark.parametrize( - ("radio_id_error", "result_reason"), - [ - (ConnectionError, "cannot_connect"), - (InvalidPinException, "invalid_auth"), - (ValueError, "unknown"), - ], -) -async def test_import_radio_id_failures( - hass: HomeAssistant, radio_id_error: Exception, result_reason: str -) -> None: - """Test various failure of get_radio_id.""" - with patch( - "homeassistant.components.frontier_silicon.config_flow.AFSAPI.get_radio_id", - side_effect=radio_id_error, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_HOST: "1.1.1.1", - CONF_PORT: 80, - CONF_PIN: "1234", - CONF_NAME: "Test name", - }, - ) - - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == result_reason - - -async def test_import_already_exists( - hass: HomeAssistant, config_entry: MockConfigEntry -) -> None: - """Test import of device which already exists.""" - config_entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_HOST: "1.1.1.1", - CONF_PORT: 80, - CONF_PIN: "1234", - CONF_NAME: "Test name", - }, - ) - - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "already_configured" - - @pytest.mark.parametrize( ("radio_id_return_value", "radio_id_side_effect"), [("mock_radio_id", None), (None, NotImplementedException)], From 1f467fcc6a333c9a10c338fd1e181e9fac2faaa5 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 26 May 2023 08:38:44 +0200 Subject: [PATCH 592/822] Remove EDL21 YAML configuration (#93551) --- homeassistant/components/edl21/config_flow.py | 13 ------- homeassistant/components/edl21/sensor.py | 39 +------------------ homeassistant/components/edl21/strings.json | 6 --- tests/components/edl21/test_config_flow.py | 30 +------------- 4 files changed, 2 insertions(+), 86 deletions(-) diff --git a/homeassistant/components/edl21/config_flow.py b/homeassistant/components/edl21/config_flow.py index b66a988958b9..0bedcc515efd 100644 --- a/homeassistant/components/edl21/config_flow.py +++ b/homeassistant/components/edl21/config_flow.py @@ -1,10 +1,8 @@ """Config flow for EDL21 integration.""" -from typing import Any import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_NAME from homeassistant.data_entry_flow import FlowResult from .const import CONF_SERIAL_PORT, DEFAULT_TITLE, DOMAIN @@ -21,17 +19,6 @@ class EDL21ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: - """Import a config entry from configuration.yaml.""" - - self._async_abort_entries_match( - {CONF_SERIAL_PORT: import_config[CONF_SERIAL_PORT]} - ) - return self.async_create_entry( - title=import_config[CONF_NAME] or DEFAULT_TITLE, - data=import_config, - ) - async def async_step_user( self, user_input: dict[str, str] | None = None ) -> FlowResult: diff --git a/homeassistant/components/edl21/sensor.py b/homeassistant/components/edl21/sensor.py index e526f951f1c6..c85f4a1f5b16 100644 --- a/homeassistant/components/edl21/sensor.py +++ b/homeassistant/components/edl21/sensor.py @@ -7,16 +7,14 @@ from typing import Any from sml import SmlGetListResponse from sml.asyncio import SmlProtocol -import voluptuous as vol from homeassistant.components.sensor import ( - PLATFORM_SCHEMA, SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_NAME, DEGREE, @@ -27,15 +25,12 @@ from homeassistant.const import ( UnitOfPower, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.dt import utcnow from .const import ( @@ -48,13 +43,6 @@ from .const import ( MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_SERIAL_PORT): cv.string, - vol.Optional(CONF_NAME, default=""): cv.string, - }, -) - # OBIS format: A-B:C.D.E*F SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( # A=1: Electricity @@ -279,31 +267,6 @@ SENSOR_UNIT_MAPPING = { } -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up EDL21 sensors via configuration.yaml and show deprecation warning.""" - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml", - breaks_in_ha_version="2023.6.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - ) - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config, - ) - ) - - async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, diff --git a/homeassistant/components/edl21/strings.json b/homeassistant/components/edl21/strings.json index 284e8229c59b..764cc41d2a40 100644 --- a/homeassistant/components/edl21/strings.json +++ b/homeassistant/components/edl21/strings.json @@ -11,11 +11,5 @@ } } } - }, - "issues": { - "deprecated_yaml": { - "title": "EDL21 YAML configuration is being removed", - "description": "Configuring EDL21 using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the EDL21 YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } } } diff --git a/tests/components/edl21/test_config_flow.py b/tests/components/edl21/test_config_flow.py index 4dbd69b23718..030ff7ae63e4 100644 --- a/tests/components/edl21/test_config_flow.py +++ b/tests/components/edl21/test_config_flow.py @@ -3,7 +3,7 @@ import pytest from homeassistant.components.edl21.const import CONF_SERIAL_PORT, DEFAULT_TITLE, DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -51,31 +51,3 @@ async def test_integration_already_exists(hass: HomeAssistant) -> None: assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" - - -async def test_create_entry_by_import(hass: HomeAssistant) -> None: - """Test that the import step works.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=VALID_LEGACY_CONFIG, - ) - - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == VALID_LEGACY_CONFIG[CONF_NAME] - assert result["data"][CONF_NAME] == VALID_LEGACY_CONFIG[CONF_NAME] - assert result["data"][CONF_SERIAL_PORT] == VALID_LEGACY_CONFIG[CONF_SERIAL_PORT] - - # Test the import step with an empty string as name - # (the name is optional in the old schema and defaults to "") - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_SERIAL_PORT: "/dev/ttyUSB2", CONF_NAME: ""}, - ) - - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == DEFAULT_TITLE - assert result["data"][CONF_NAME] == "" - assert result["data"][CONF_SERIAL_PORT] == "/dev/ttyUSB2" From af9ee8bc4a2acc69e412c2c6548ca0fba7626f75 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 26 May 2023 08:41:21 +0200 Subject: [PATCH 593/822] Remove obihai YAMl configuration (#93549) --- .../components/obihai/config_flow.py | 27 +--------- homeassistant/components/obihai/sensor.py | 49 ++----------------- homeassistant/components/obihai/strings.json | 6 --- tests/components/obihai/test_config_flow.py | 48 ------------------ 4 files changed, 4 insertions(+), 126 deletions(-) diff --git a/homeassistant/components/obihai/config_flow.py b/homeassistant/components/obihai/config_flow.py index 6216fe0b9730..ce5cca6a7a4f 100644 --- a/homeassistant/components/obihai/config_flow.py +++ b/homeassistant/components/obihai/config_flow.py @@ -10,7 +10,7 @@ import voluptuous as vol from homeassistant.components import dhcp from homeassistant.config_entries import ConfigFlow -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult @@ -135,28 +135,3 @@ class ObihaiFlowHandler(ConfigFlow, domain=DOMAIN): ) return await self.async_step_user(user_input=user_input) - - # DEPRECATED - async def async_step_import(self, config: dict[str, Any]) -> FlowResult: - """Handle a flow initialized by importing a config.""" - - try: - _ = await self.hass.async_add_executor_job(gethostbyname, config[CONF_HOST]) - except gaierror: - return self.async_abort(reason="cannot_connect") - - if pyobihai := await async_validate_creds(self.hass, config): - device_mac = await self.hass.async_add_executor_job(pyobihai.get_device_mac) - await self.async_set_unique_id(device_mac) - self._abort_if_unique_id_configured() - - return self.async_create_entry( - title=config.get(CONF_NAME, config[CONF_HOST]), - data={ - CONF_HOST: config[CONF_HOST], - CONF_PASSWORD: config[CONF_PASSWORD], - CONF_USERNAME: config[CONF_USERNAME], - }, - ) - - return self.async_abort(reason="invalid_auth") diff --git a/homeassistant/components/obihai/sensor.py b/homeassistant/components/obihai/sensor.py index 61411b0ce271..b838d0ec92b5 100644 --- a/homeassistant/components/obihai/sensor.py +++ b/homeassistant/components/obihai/sensor.py @@ -4,61 +4,18 @@ from __future__ import annotations from datetime import timedelta from pyobihai import PyObihai -import voluptuous as vol -from homeassistant.components.sensor import ( - PLATFORM_SCHEMA, - SensorDeviceClass, - SensorEntity, -) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .connectivity import ObihaiConnection -from .const import DEFAULT_PASSWORD, DEFAULT_USERNAME, DOMAIN, OBIHAI +from .const import OBIHAI SCAN_INTERVAL = timedelta(seconds=5) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, - vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string, - } -) - - -# DEPRECATED -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Obihai sensor platform.""" - ir.async_create_issue( - hass, - DOMAIN, - "manual_migration", - breaks_in_ha_version="2023.6.0", - is_fixable=False, - severity=ir.IssueSeverity.WARNING, - translation_key="manual_migration", - ) - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config, - ) - ) - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback diff --git a/homeassistant/components/obihai/strings.json b/homeassistant/components/obihai/strings.json index 1b91cd606543..823bc2e1b8de 100644 --- a/homeassistant/components/obihai/strings.json +++ b/homeassistant/components/obihai/strings.json @@ -24,11 +24,5 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } - }, - "issues": { - "manual_migration": { - "title": "Obihai YAML configuration is being removed", - "description": "Configuration of the Obihai platform in YAML is deprecated and will be removed in Home Assistant 2023.6; Your existing configuration has been imported into the UI automatically and can be safely removed from your configuration.yaml file." - } } } diff --git a/tests/components/obihai/test_config_flow.py b/tests/components/obihai/test_config_flow.py index 1743b81a0e9e..d9ca52424c2f 100644 --- a/tests/components/obihai/test_config_flow.py +++ b/tests/components/obihai/test_config_flow.py @@ -78,54 +78,6 @@ async def test_connect_failure(hass: HomeAssistant, mock_gaierror: Generator) -> assert result["errors"]["base"] == "cannot_connect" -async def test_yaml_import(hass: HomeAssistant) -> None: - """Test we get the YAML imported.""" - - with patch(VALIDATE_AUTH_PATCH, return_value=MockPyObihai()): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=USER_INPUT, - ) - await hass.async_block_till_done() - - assert result["type"] == FlowResultType.CREATE_ENTRY - assert "errors" not in result - - -async def test_yaml_import_auth_fail(hass: HomeAssistant) -> None: - """Test the YAML import fails.""" - - with patch(VALIDATE_AUTH_PATCH, return_value=False): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=USER_INPUT, - ) - await hass.async_block_till_done() - - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "invalid_auth" - assert "errors" not in result - - -async def test_yaml_import_connect_fail( - hass: HomeAssistant, mock_gaierror: Generator -) -> None: - """Test the YAML import fails with invalid host.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=USER_INPUT, - ) - await hass.async_block_till_done() - - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "cannot_connect" - assert "errors" not in result - - async def test_dhcp_flow(hass: HomeAssistant) -> None: """Test that DHCP discovery works.""" From b51f82719efc7c84026741b3d141b8463a2f95ba Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 26 May 2023 12:39:52 +0200 Subject: [PATCH 594/822] Update frontend to 20230526.0 (#93581) --- 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 4e1e0a74fe9b..a7e110055704 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==20230503.3"] + "requirements": ["home-assistant-frontend==20230526.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 9112959935f0..5816763c8d26 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -25,7 +25,7 @@ ha-av==10.1.0 hass-nabucasa==0.67.1 hassil==1.0.6 home-assistant-bluetooth==1.10.0 -home-assistant-frontend==20230503.3 +home-assistant-frontend==20230526.0 home-assistant-intents==2023.4.26 httpx==0.24.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index ced1c1d6f33e..80d26a914588 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -920,7 +920,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230503.3 +home-assistant-frontend==20230526.0 # homeassistant.components.conversation home-assistant-intents==2023.4.26 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a34cc1a09701..2fe719ae3892 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -712,7 +712,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230503.3 +home-assistant-frontend==20230526.0 # homeassistant.components.conversation home-assistant-intents==2023.4.26 From 5b654c8b797e202142c58a8ad07ccfdf35181019 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 26 May 2023 15:20:46 +0200 Subject: [PATCH 595/822] Fix mill tests (#93596) --- tests/components/mill/test_init.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/components/mill/test_init.py b/tests/components/mill/test_init.py index c37c28c7b96c..2c17a2d75503 100644 --- a/tests/components/mill/test_init.py +++ b/tests/components/mill/test_init.py @@ -23,7 +23,7 @@ async def test_setup_with_cloud_config(hass: HomeAssistant) -> None: with patch( "mill.Mill.fetch_heater_and_sensor_data", return_value={} ) as mock_fetch, patch("mill.Mill.connect", return_value=True) as mock_connect: - assert await async_setup_component(hass, "mill", entry) + assert await async_setup_component(hass, "mill", {}) assert len(mock_fetch.mock_calls) == 1 assert len(mock_connect.mock_calls) == 1 @@ -40,7 +40,7 @@ async def test_setup_with_cloud_config_fails(hass: HomeAssistant) -> None: ) entry.add_to_hass(hass) with patch("mill.Mill.connect", return_value=False): - assert await async_setup_component(hass, "mill", entry) + assert await async_setup_component(hass, "mill", {}) assert entry.state is ConfigEntryState.SETUP_RETRY @@ -57,7 +57,7 @@ async def test_setup_with_old_cloud_config(hass: HomeAssistant) -> None: with patch("mill.Mill.fetch_heater_and_sensor_data", return_value={}), patch( "mill.Mill.connect", return_value=True ) as mock_connect: - assert await async_setup_component(hass, "mill", entry) + assert await async_setup_component(hass, "mill", {}) assert len(mock_connect.mock_calls) == 1 @@ -90,7 +90,7 @@ async def test_setup_with_local_config(hass: HomeAssistant) -> None: "status": "ok", }, ) as mock_connect: - assert await async_setup_component(hass, "mill", entry) + assert await async_setup_component(hass, "mill", {}) assert len(mock_fetch.mock_calls) == 1 assert len(mock_connect.mock_calls) == 1 @@ -115,7 +115,7 @@ async def test_unload_entry(hass: HomeAssistant) -> None: ), patch( "mill.Mill.connect", return_value=True ): - assert await async_setup_component(hass, "mill", entry) + assert await async_setup_component(hass, "mill", {}) assert await hass.config_entries.async_unload(entry.entry_id) From 5671934b347da9fb2362f54185c3c19d39930a3f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 26 May 2023 15:35:06 +0200 Subject: [PATCH 596/822] Fix sharkiq tests (#93597) --- tests/components/sharkiq/test_config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/sharkiq/test_config_flow.py b/tests/components/sharkiq/test_config_flow.py index 248bb57f04c6..a98eff6f2bb0 100644 --- a/tests/components/sharkiq/test_config_flow.py +++ b/tests/components/sharkiq/test_config_flow.py @@ -29,7 +29,7 @@ async def test_setup_success_no_region(hass: HomeAssistant) -> None: ) mock_config.add_to_hass(hass) - result = await async_setup_component(hass=hass, domain=DOMAIN, config=mock_config) + result = await async_setup_component(hass=hass, domain=DOMAIN, config={}) assert result is True From 0bf9bb15f62b46e88b62fdff5b24fcc632408d09 Mon Sep 17 00:00:00 2001 From: Paul Frank Date: Fri, 26 May 2023 16:22:59 +0200 Subject: [PATCH 597/822] Use entity attributes in mystrom (#93591) * Migrated proprties to _attr_ * Update homeassistant/components/mystrom/light.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/mystrom/light.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/mystrom/light.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/mystrom/light.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/mystrom/light.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/mystrom/light.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/mystrom/switch.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/mystrom/switch.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * review comment --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/mystrom/light.py | 65 +++++----------------- homeassistant/components/mystrom/switch.py | 33 ++--------- 2 files changed, 20 insertions(+), 78 deletions(-) diff --git a/homeassistant/components/mystrom/light.py b/homeassistant/components/mystrom/light.py index 26ce5b115671..e01cebb818d4 100644 --- a/homeassistant/components/mystrom/light.py +++ b/homeassistant/components/mystrom/light.py @@ -31,8 +31,6 @@ DEFAULT_NAME = "myStrom bulb" EFFECT_RAINBOW = "rainbow" EFFECT_SUNRISE = "sunrise" -MYSTROM_EFFECT_LIST = [EFFECT_RAINBOW, EFFECT_SUNRISE] - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_HOST): cv.string, @@ -74,52 +72,15 @@ class MyStromLight(LightEntity): _attr_color_mode = ColorMode.HS _attr_supported_color_modes = {ColorMode.HS} _attr_supported_features = LightEntityFeature.EFFECT | LightEntityFeature.FLASH + _attr_effect_list = [EFFECT_RAINBOW, EFFECT_SUNRISE] def __init__(self, bulb, name, mac): """Initialize the light.""" self._bulb = bulb - self._name = name - self._state = None - self._available = False - self._brightness = 0 - self._color_h = 0 - self._color_s = 0 - self._mac = mac - - @property - def name(self): - """Return the display name of this light.""" - return self._name - - @property - def unique_id(self): - """Return a unique ID.""" - return self._mac - - @property - def brightness(self): - """Return the brightness of the light.""" - return self._brightness - - @property - def hs_color(self): - """Return the color of the light.""" - return self._color_h, self._color_s - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._available - - @property - def effect_list(self): - """Return the list of supported effects.""" - return MYSTROM_EFFECT_LIST - - @property - def is_on(self): - """Return true if light is on.""" - return self._state + self._attr_name = name + self._attr_available = False + self._attr_unique_id = mac + self._attr_hs_color = 0, 0 async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the light.""" @@ -130,7 +91,10 @@ class MyStromLight(LightEntity): color_h, color_s = kwargs[ATTR_HS_COLOR] elif ATTR_BRIGHTNESS in kwargs: # Brightness update, keep color - color_h, color_s = self._color_h, self._color_s + if self.hs_color is not None: + color_h, color_s = self.hs_color + else: + color_h, color_s = 0, 0 # Back to white else: color_h, color_s = 0, 0 # Back to white @@ -159,7 +123,7 @@ class MyStromLight(LightEntity): """Fetch new state data for this light.""" try: await self._bulb.get_state() - self._state = self._bulb.state + self._attr_is_on = self._bulb.state colors = self._bulb.color try: @@ -168,11 +132,10 @@ class MyStromLight(LightEntity): color_s, color_v = colors.split(";") color_h = 0 - self._color_h = int(color_h) - self._color_s = int(color_s) - self._brightness = int(color_v) * 255 / 100 + self._attr_hs_color = int(color_h), int(color_s) + self._attr_brightness = int(int(color_v) * 255 / 100) - self._available = True + self._attr_available = True except MyStromConnectionError: _LOGGER.warning("No route to myStrom bulb") - self._available = False + self._attr_available = False diff --git a/homeassistant/components/mystrom/switch.py b/homeassistant/components/mystrom/switch.py index 7bce3000424c..3d073693b1ea 100644 --- a/homeassistant/components/mystrom/switch.py +++ b/homeassistant/components/mystrom/switch.py @@ -53,30 +53,9 @@ class MyStromSwitch(SwitchEntity): def __init__(self, plug, name): """Initialize the myStrom switch/plug.""" - self._name = name self.plug = plug - self._available = True - self.relay = None - - @property - def name(self): - """Return the name of the switch.""" - return self._name - - @property - def is_on(self): - """Return true if switch is on.""" - return bool(self.relay) - - @property - def unique_id(self): - """Return a unique ID.""" - return self.plug._mac # pylint: disable=protected-access - - @property - def available(self): - """Could the device be accessed during the last update call.""" - return self._available + self._attr_name = name + self._attr_unique_id = self.plug.mac async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" @@ -96,9 +75,9 @@ class MyStromSwitch(SwitchEntity): """Get the latest data from the device and update the data.""" try: await self.plug.get_state() - self.relay = self.plug.relay - self._available = True + self._attr_is_on = self.plug.relay + self._attr_available = True except MyStromConnectionError: - if self._available: - self._available = False + if self.available: + self._attr_available = False _LOGGER.error("No route to myStrom plug") From d4462829f53a549de8347868da6a37b0066c0186 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 26 May 2023 16:29:32 +0200 Subject: [PATCH 598/822] Add CONFIG_SCHEMA to analytics (#93598) --- homeassistant/components/analytics/__init__.py | 3 +++ homeassistant/helpers/config_validation.py | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/homeassistant/components/analytics/__init__.py b/homeassistant/components/analytics/__init__.py index c02c1a3a3b6e..ee36aa78e63f 100644 --- a/homeassistant/components/analytics/__init__.py +++ b/homeassistant/components/analytics/__init__.py @@ -6,12 +6,15 @@ import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.core import Event, HassJob, HomeAssistant, callback +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_call_later, async_track_time_interval from homeassistant.helpers.typing import ConfigType from .analytics import Analytics from .const import ATTR_ONBOARDED, ATTR_PREFERENCES, DOMAIN, INTERVAL, PREFERENCE_SCHEMA +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, _: ConfigType) -> bool: """Set up the analytics integration.""" diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index e0924b928ca5..bbe4e05fd344 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -1045,6 +1045,11 @@ def expand_condition_shorthand(value: Any | None) -> Any: # Schemas +def empty_config_schema(domain: str) -> vol.Schema: + """Return a config schema which accepts no configuration parameters.""" + return vol.Schema({vol.Optional(domain): vol.Schema({})}, extra=vol.ALLOW_EXTRA) + + PLATFORM_SCHEMA = vol.Schema( { vol.Required(CONF_PLATFORM): string, From 236ee8be0407cfe0b6eea32b00724f9f9ceeffe7 Mon Sep 17 00:00:00 2001 From: Raj Laud <50647620+rajlaud@users.noreply.github.com> Date: Fri, 26 May 2023 15:53:40 -0400 Subject: [PATCH 599/822] Bump pysqueezebox to 0.6.2 (#93573) --- homeassistant/components/squeezebox/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/squeezebox/manifest.json b/homeassistant/components/squeezebox/manifest.json index a2efc90d517b..43c2868dd69a 100644 --- a/homeassistant/components/squeezebox/manifest.json +++ b/homeassistant/components/squeezebox/manifest.json @@ -12,5 +12,5 @@ "documentation": "https://www.home-assistant.io/integrations/squeezebox", "iot_class": "local_polling", "loggers": ["pysqueezebox"], - "requirements": ["pysqueezebox==0.6.1"] + "requirements": ["pysqueezebox==0.6.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 80d26a914588..6afbe71fb17d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2005,7 +2005,7 @@ pysoma==0.0.12 pyspcwebgw==0.4.0 # homeassistant.components.squeezebox -pysqueezebox==0.6.1 +pysqueezebox==0.6.3 # homeassistant.components.stiebel_eltron pystiebeleltron==0.0.1.dev2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2fe719ae3892..36239b0bce39 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1485,7 +1485,7 @@ pysoma==0.0.12 pyspcwebgw==0.4.0 # homeassistant.components.squeezebox -pysqueezebox==0.6.1 +pysqueezebox==0.6.3 # homeassistant.components.switchbee pyswitchbee==1.8.0 From c5e425d0756c7404da261c742ccc19e8d838997a Mon Sep 17 00:00:00 2001 From: Artem Draft Date: Sat, 27 May 2023 10:49:57 +0300 Subject: [PATCH 600/822] Bump androidtvremote2 to 0.0.9 (#93634) --- homeassistant/components/androidtv_remote/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/androidtv_remote/manifest.json b/homeassistant/components/androidtv_remote/manifest.json index 9273e82a51b7..c728ea0a6827 100644 --- a/homeassistant/components/androidtv_remote/manifest.json +++ b/homeassistant/components/androidtv_remote/manifest.json @@ -8,6 +8,6 @@ "iot_class": "local_push", "loggers": ["androidtvremote2"], "quality_scale": "platinum", - "requirements": ["androidtvremote2==0.0.8"], + "requirements": ["androidtvremote2==0.0.9"], "zeroconf": ["_androidtvremote2._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 6afbe71fb17d..6e3e9cd6eaec 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -336,7 +336,7 @@ amcrest==1.9.7 androidtv[async]==0.0.70 # homeassistant.components.androidtv_remote -androidtvremote2==0.0.8 +androidtvremote2==0.0.9 # homeassistant.components.anel_pwrctrl anel_pwrctrl-homeassistant==0.0.1.dev2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 36239b0bce39..ce06896f2227 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -311,7 +311,7 @@ ambiclimate==0.2.1 androidtv[async]==0.0.70 # homeassistant.components.androidtv_remote -androidtvremote2==0.0.8 +androidtvremote2==0.0.9 # homeassistant.components.anova anova-wifi==0.10.0 From bb170a2bbf4583784b216bf4576a7734b4152ca4 Mon Sep 17 00:00:00 2001 From: Aaron Godfrey Date: Sat, 27 May 2023 10:09:11 -0700 Subject: [PATCH 601/822] Fix todoist end time for tasks with due date in the future (#91874) Fix end time for tasks with due date in the future. Co-authored-by: Allen Porter --- homeassistant/components/todoist/calendar.py | 8 +++---- tests/components/todoist/test_calendar.py | 25 ++++++++++++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/todoist/calendar.py b/homeassistant/components/todoist/calendar.py index 1c356a857a68..33e9e9c8d2cd 100644 --- a/homeassistant/components/todoist/calendar.py +++ b/homeassistant/components/todoist/calendar.py @@ -120,7 +120,7 @@ async def async_setup_platform( api = TodoistAPIAsync(token) coordinator = TodoistCoordinator(hass, _LOGGER, SCAN_INTERVAL, api) - await coordinator.async_config_entry_first_refresh() + await coordinator.async_refresh() async def _shutdown_coordinator(_: Event) -> None: await coordinator.async_shutdown() @@ -477,16 +477,16 @@ class TodoistProjectData: end = dt.parse_datetime( data.due.datetime if data.due.datetime else data.due.date ) - task[END] = dt.as_utc(end) if end is not None else end + task[END] = dt.as_local(end) if end is not None else end if task[END] is not None: if self._due_date_days is not None and ( - task[END] > dt.utcnow() + self._due_date_days + task[END] > dt.now() + self._due_date_days ): # This task is out of range of our due date; # it shouldn't be counted. return None - task[DUE_TODAY] = task[END].date() == dt.utcnow().date() + task[DUE_TODAY] = task[END].date() == dt.now().date() # Special case: Task is overdue. if task[END] <= task[START]: diff --git a/tests/components/todoist/test_calendar.py b/tests/components/todoist/test_calendar.py index 5a6a05623c74..7cbb73c4e922 100644 --- a/tests/components/todoist/test_calendar.py +++ b/tests/components/todoist/test_calendar.py @@ -1,4 +1,5 @@ """Unit tests for the Todoist calendar platform.""" +from datetime import timedelta from http import HTTPStatus from typing import Any from unittest.mock import AsyncMock, patch @@ -182,6 +183,30 @@ async def test_update_entity_for_custom_project_no_due_date_on( assert state.state == "on" +@pytest.mark.parametrize( + "due", + [ + Due( + date=(dt.now() + timedelta(days=3)).strftime("%Y-%m-%d"), + is_recurring=False, + string="3 days from today", + ) + ], +) +async def test_update_entity_for_calendar_with_due_date_in_the_future( + hass: HomeAssistant, + api: AsyncMock, +) -> None: + """Test that a task with a due date in the future has on state and correct end_time.""" + await async_update_entity(hass, "calendar.name") + state = hass.states.get("calendar.name") + assert state.state == "on" + + # The end time should be in the user's timezone + expected_end_time = (dt.now() + timedelta(days=3)).strftime("%Y-%m-%d 00:00:00") + assert state.attributes["end_time"] == expected_end_time + + @pytest.mark.parametrize("setup_integration", [None]) async def test_failed_coordinator_update(hass: HomeAssistant, api: AsyncMock) -> None: """Test a failed data coordinator update is handled correctly.""" From e4c51d43f0351a9f39a68cd4266cd88235de9d06 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 27 May 2023 20:21:12 +0200 Subject: [PATCH 602/822] Add YouTube integration (#92988) * Add YouTube stub * Add YouTube stub * Add YouTube stub * Add YouTube stub * Add Youtube stub * Add Youtube stub * Add tests * Add tests * Add tests * Clean up * Add test for options flow * Fix feedback * Fix feedback * Remove obsolete request * Catch exceptions * Parallelize latest video calls * Apply suggestions from code review Co-authored-by: Robert Hillis * Add youtube to google brands * Fix feedback * Fix feedback * Fix test * Fix test * Add unit test for http error * Update homeassistant/components/youtube/coordinator.py Co-authored-by: Robert Hillis * Fix black * Fix feedback * Fix feedback * Fix tests --------- Co-authored-by: Robert Hillis --- CODEOWNERS | 2 + homeassistant/brands/google.json | 3 +- homeassistant/components/youtube/__init__.py | 55 ++++ homeassistant/components/youtube/api.py | 47 ++++ .../youtube/application_credentials.py | 11 + .../components/youtube/config_flow.py | 121 ++++++++ homeassistant/components/youtube/const.py | 22 ++ .../components/youtube/coordinator.py | 90 ++++++ homeassistant/components/youtube/entity.py | 38 +++ .../components/youtube/manifest.json | 11 + homeassistant/components/youtube/sensor.py | 86 ++++++ homeassistant/components/youtube/strings.json | 42 +++ .../generated/application_credentials.py | 1 + homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 1 + requirements_test_all.txt | 1 + tests/components/youtube/__init__.py | 68 +++++ tests/components/youtube/conftest.py | 116 ++++++++ .../youtube/fixtures/get_channel.json | 55 ++++ .../youtube/fixtures/get_channel_2.json | 42 +++ .../youtube/fixtures/get_playlist_items.json | 266 ++++++++++++++++++ .../youtube/fixtures/get_subscriptions.json | 37 +++ tests/components/youtube/test_config_flow.py | 171 +++++++++++ tests/components/youtube/test_init.py | 135 +++++++++ tests/components/youtube/test_sensor.py | 29 ++ 26 files changed, 1456 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/youtube/__init__.py create mode 100644 homeassistant/components/youtube/api.py create mode 100644 homeassistant/components/youtube/application_credentials.py create mode 100644 homeassistant/components/youtube/config_flow.py create mode 100644 homeassistant/components/youtube/const.py create mode 100644 homeassistant/components/youtube/coordinator.py create mode 100644 homeassistant/components/youtube/entity.py create mode 100644 homeassistant/components/youtube/manifest.json create mode 100644 homeassistant/components/youtube/sensor.py create mode 100644 homeassistant/components/youtube/strings.json create mode 100644 tests/components/youtube/__init__.py create mode 100644 tests/components/youtube/conftest.py create mode 100644 tests/components/youtube/fixtures/get_channel.json create mode 100644 tests/components/youtube/fixtures/get_channel_2.json create mode 100644 tests/components/youtube/fixtures/get_playlist_items.json create mode 100644 tests/components/youtube/fixtures/get_subscriptions.json create mode 100644 tests/components/youtube/test_config_flow.py create mode 100644 tests/components/youtube/test_init.py create mode 100644 tests/components/youtube/test_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index fd7d0725b5bc..f01a1173b20e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1418,6 +1418,8 @@ build.json @home-assistant/supervisor /tests/components/yolink/ @matrixd2 /homeassistant/components/youless/ @gjong /tests/components/youless/ @gjong +/homeassistant/components/youtube/ @joostlek +/tests/components/youtube/ @joostlek /homeassistant/components/zamg/ @killer0071234 /tests/components/zamg/ @killer0071234 /homeassistant/components/zengge/ @emontnemery diff --git a/homeassistant/brands/google.json b/homeassistant/brands/google.json index 3eb2e9e64f01..ce71457a656d 100644 --- a/homeassistant/brands/google.json +++ b/homeassistant/brands/google.json @@ -17,6 +17,7 @@ "google", "nest", "cast", - "dialogflow" + "dialogflow", + "youtube" ] } diff --git a/homeassistant/components/youtube/__init__.py b/homeassistant/components/youtube/__init__.py new file mode 100644 index 000000000000..52e30d8093f4 --- /dev/null +++ b/homeassistant/components/youtube/__init__.py @@ -0,0 +1,55 @@ +"""Support for YouTube.""" +from __future__ import annotations + +from aiohttp.client_exceptions import ClientError, ClientResponseError + +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 homeassistant.helpers.config_entry_oauth2_flow import ( + OAuth2Session, + async_get_config_entry_implementation, +) + +from .api import AsyncConfigEntryAuth +from .const import AUTH, COORDINATOR, DOMAIN +from .coordinator import YouTubeDataUpdateCoordinator + +PLATFORMS = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up YouTube from a config entry.""" + implementation = await async_get_config_entry_implementation(hass, entry) + session = OAuth2Session(hass, entry, implementation) + auth = AsyncConfigEntryAuth(hass, async_get_clientsession(hass), session) + try: + await auth.check_and_refresh_token() + except ClientResponseError as err: + if 400 <= err.status < 500: + raise ConfigEntryNotReady( + "OAuth session is not valid, reauth required" + ) from err + raise ConfigEntryNotReady from err + except ClientError as err: + raise ConfigEntryNotReady from err + coordinator = YouTubeDataUpdateCoordinator(hass, auth) + + await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { + COORDINATOR: coordinator, + AUTH: auth, + } + await hass.config_entries.async_forward_entry_setups(entry, list(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/youtube/api.py b/homeassistant/components/youtube/api.py new file mode 100644 index 000000000000..70980987c338 --- /dev/null +++ b/homeassistant/components/youtube/api.py @@ -0,0 +1,47 @@ +"""API for YouTube bound to Home Assistant OAuth.""" +from aiohttp import ClientSession +from google.oauth2.credentials import Credentials +from google.oauth2.utils import OAuthClientAuthHandler +from googleapiclient.discovery import Resource, build + +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_entry_oauth2_flow + + +class AsyncConfigEntryAuth(OAuthClientAuthHandler): + """Provide Google authentication tied to an OAuth2 based config entry.""" + + def __init__( + self, + hass: HomeAssistant, + websession: ClientSession, + oauth2_session: config_entry_oauth2_flow.OAuth2Session, + ) -> None: + """Initialize YouTube Auth.""" + self.oauth_session = oauth2_session + self.hass = hass + super().__init__(websession) + + @property + def access_token(self) -> str: + """Return the access token.""" + return self.oauth_session.token[CONF_ACCESS_TOKEN] + + async def check_and_refresh_token(self) -> str: + """Check the token.""" + await self.oauth_session.async_ensure_token_valid() + return self.access_token + + async def get_resource(self) -> Resource: + """Create executor job to get current resource.""" + credentials = Credentials(await self.check_and_refresh_token()) + return await self.hass.async_add_executor_job(self._get_resource, credentials) + + def _get_resource(self, credentials: Credentials) -> Resource: + """Get current resource.""" + return build( + "youtube", + "v3", + credentials=credentials, + ) diff --git a/homeassistant/components/youtube/application_credentials.py b/homeassistant/components/youtube/application_credentials.py new file mode 100644 index 000000000000..ba6188d9a3dc --- /dev/null +++ b/homeassistant/components/youtube/application_credentials.py @@ -0,0 +1,11 @@ +"""application_credentials platform for YouTube.""" +from homeassistant.components.application_credentials import AuthorizationServer +from homeassistant.core import HomeAssistant + + +async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: + """Return authorization server.""" + return AuthorizationServer( + "https://accounts.google.com/o/oauth2/v2/auth", + "https://oauth2.googleapis.com/token", + ) diff --git a/homeassistant/components/youtube/config_flow.py b/homeassistant/components/youtube/config_flow.py new file mode 100644 index 000000000000..063238c07c93 --- /dev/null +++ b/homeassistant/components/youtube/config_flow.py @@ -0,0 +1,121 @@ +"""Config flow for YouTube integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from google.oauth2.credentials import Credentials +from googleapiclient.discovery import Resource, build +from googleapiclient.errors import HttpError +from googleapiclient.http import HttpRequest +import voluptuous as vol + +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, +) + +from .const import CONF_CHANNELS, DEFAULT_ACCESS, DOMAIN, LOGGER + + +class OAuth2FlowHandler( + config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN +): + """Config flow to handle Google OAuth2 authentication.""" + + _data: dict[str, Any] = {} + _title: str = "" + + DOMAIN = DOMAIN + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) + + @property + def extra_authorize_data(self) -> dict[str, Any]: + """Extra data that needs to be appended to the authorize url.""" + return { + "scope": " ".join(DEFAULT_ACCESS), + # Add params to ensure we get back a refresh token + "access_type": "offline", + "prompt": "consent", + } + + async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult: + """Create an entry for the flow, or update existing entry.""" + try: + service = await self._get_resource(data[CONF_TOKEN][CONF_ACCESS_TOKEN]) + # pylint: disable=no-member + own_channel_request: HttpRequest = service.channels().list( + part="snippet", mine=True + ) + response = await self.hass.async_add_executor_job( + own_channel_request.execute + ) + own_channel = response["items"][0] + except HttpError as ex: + error = ex.reason + return self.async_abort( + reason="access_not_configured", + description_placeholders={"message": error}, + ) + except Exception as ex: # pylint: disable=broad-except + LOGGER.error("Unknown error occurred: %s", ex.args) + return self.async_abort(reason="unknown") + self._title = own_channel["snippet"]["title"] + self._data = data + + await self.async_set_unique_id(own_channel["id"]) + self._abort_if_unique_id_configured() + + return await self.async_step_channels() + + async def async_step_channels( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Select which channels to track.""" + if user_input: + return self.async_create_entry( + title=self._title, + data=self._data, + options=user_input, + ) + service = await self._get_resource(self._data[CONF_TOKEN][CONF_ACCESS_TOKEN]) + # pylint: disable=no-member + subscription_request: HttpRequest = service.subscriptions().list( + part="snippet", mine=True, maxResults=50 + ) + response = await self.hass.async_add_executor_job(subscription_request.execute) + selectable_channels = [ + SelectOptionDict( + value=subscription["snippet"]["resourceId"]["channelId"], + label=subscription["snippet"]["title"], + ) + for subscription in response["items"] + ] + return self.async_show_form( + step_id="channels", + data_schema=vol.Schema( + { + vol.Required(CONF_CHANNELS): SelectSelector( + SelectSelectorConfig(options=selectable_channels, multiple=True) + ), + } + ), + ) + + async def _get_resource(self, token: str) -> Resource: + def _build_resource() -> Resource: + return build( + "youtube", + "v3", + credentials=Credentials(token), + ) + + return await self.hass.async_add_executor_job(_build_resource) diff --git a/homeassistant/components/youtube/const.py b/homeassistant/components/youtube/const.py new file mode 100644 index 000000000000..e2757e3856d4 --- /dev/null +++ b/homeassistant/components/youtube/const.py @@ -0,0 +1,22 @@ +"""Constants for YouTube integration.""" +import logging + +DEFAULT_ACCESS = ["https://www.googleapis.com/auth/youtube.readonly"] +DOMAIN = "youtube" +MANUFACTURER = "Google, Inc." + +CONF_CHANNELS = "channels" +CONF_ID = "id" +CONF_UPLOAD_PLAYLIST = "upload_playlist_id" +COORDINATOR = "coordinator" +AUTH = "auth" + +LOGGER = logging.getLogger(__package__) + +ATTR_TITLE = "title" +ATTR_LATEST_VIDEO = "latest_video" +ATTR_SUBSCRIBER_COUNT = "subscriber_count" +ATTR_DESCRIPTION = "description" +ATTR_THUMBNAIL = "thumbnail" +ATTR_VIDEO_ID = "video_id" +ATTR_PUBLISHED_AT = "published_at" diff --git a/homeassistant/components/youtube/coordinator.py b/homeassistant/components/youtube/coordinator.py new file mode 100644 index 000000000000..4cbe19d48450 --- /dev/null +++ b/homeassistant/components/youtube/coordinator.py @@ -0,0 +1,90 @@ +"""DataUpdateCoordinator for the YouTube integration.""" +from __future__ import annotations + +import asyncio +from datetime import timedelta +from typing import Any + +from googleapiclient.discovery import Resource +from googleapiclient.http import HttpRequest + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_ICON, ATTR_ID +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from . import AsyncConfigEntryAuth +from .const import ( + ATTR_DESCRIPTION, + ATTR_LATEST_VIDEO, + ATTR_PUBLISHED_AT, + ATTR_SUBSCRIBER_COUNT, + ATTR_THUMBNAIL, + ATTR_TITLE, + ATTR_VIDEO_ID, + CONF_CHANNELS, + DOMAIN, + LOGGER, +) + + +def get_upload_playlist_id(channel_id: str) -> str: + """Return the playlist id with the uploads of the channel. + + Replacing the UC in the channel id (UCxxxxxxxxxxxx) with UU is the way to do it without extra request (UUxxxxxxxxxxxx). + """ + return channel_id.replace("UC", "UU", 1) + + +class YouTubeDataUpdateCoordinator(DataUpdateCoordinator): + """A YouTube Data Update Coordinator.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, auth: AsyncConfigEntryAuth) -> None: + """Initialize the YouTube data coordinator.""" + self._auth = auth + super().__init__( + hass, + LOGGER, + name=DOMAIN, + update_interval=timedelta(minutes=15), + ) + + async def _async_update_data(self) -> dict[str, Any]: + data = {} + service = await self._auth.get_resource() + channels = self.config_entry.options[CONF_CHANNELS] + channel_request: HttpRequest = service.channels().list( + part="snippet,statistics", id=",".join(channels), maxResults=50 + ) + response: dict = await self.hass.async_add_executor_job(channel_request.execute) + + async def _compile_data(channel: dict[str, Any]) -> None: + data[channel["id"]] = { + ATTR_ID: channel["id"], + ATTR_TITLE: channel["snippet"]["title"], + ATTR_ICON: channel["snippet"]["thumbnails"]["high"]["url"], + ATTR_LATEST_VIDEO: await self._get_latest_video(service, channel["id"]), + ATTR_SUBSCRIBER_COUNT: int(channel["statistics"]["subscriberCount"]), + } + + await asyncio.gather(*[_compile_data(channel) for channel in response["items"]]) + return data + + async def _get_latest_video( + self, service: Resource, channel_id: str + ) -> dict[str, Any]: + playlist_id = get_upload_playlist_id(channel_id) + job: HttpRequest = service.playlistItems().list( + part="snippet,contentDetails", playlistId=playlist_id, maxResults=1 + ) + response: dict = await self.hass.async_add_executor_job(job.execute) + video = response["items"][0] + return { + ATTR_PUBLISHED_AT: video["snippet"]["publishedAt"], + ATTR_TITLE: video["snippet"]["title"], + ATTR_DESCRIPTION: video["snippet"]["description"], + ATTR_THUMBNAIL: video["snippet"]["thumbnails"]["standard"]["url"], + ATTR_VIDEO_ID: video["contentDetails"]["videoId"], + } diff --git a/homeassistant/components/youtube/entity.py b/homeassistant/components/youtube/entity.py new file mode 100644 index 000000000000..0f1d0c0aeb92 --- /dev/null +++ b/homeassistant/components/youtube/entity.py @@ -0,0 +1,38 @@ +"""Entity representing a YouTube account.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.const import ATTR_ID +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription + +from .const import ATTR_TITLE, DOMAIN, MANUFACTURER +from .coordinator import YouTubeDataUpdateCoordinator + + +class YouTubeChannelEntity(Entity): + """An HA implementation for YouTube entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: YouTubeDataUpdateCoordinator, + description: EntityDescription, + channel: dict[str, Any], + ) -> None: + """Initialize a Google Mail entity.""" + self.entity_description = description + self._attr_unique_id = ( + f"{coordinator.config_entry.entry_id}_{channel[ATTR_ID]}_{description.key}" + ) + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={ + (DOMAIN, f"{coordinator.config_entry.entry_id}_{channel[ATTR_ID]}") + }, + manufacturer=MANUFACTURER, + name=channel[ATTR_TITLE], + ) + self._channel = channel diff --git a/homeassistant/components/youtube/manifest.json b/homeassistant/components/youtube/manifest.json new file mode 100644 index 000000000000..fbc02bda0069 --- /dev/null +++ b/homeassistant/components/youtube/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "youtube", + "name": "YouTube", + "codeowners": ["@joostlek"], + "config_flow": true, + "dependencies": ["application_credentials"], + "documentation": "https://www.home-assistant.io/integrations/youtube", + "integration_type": "service", + "iot_class": "cloud_polling", + "requirements": ["google-api-python-client==2.71.0"] +} diff --git a/homeassistant/components/youtube/sensor.py b/homeassistant/components/youtube/sensor.py new file mode 100644 index 000000000000..6c75ef3bf8cf --- /dev/null +++ b/homeassistant/components/youtube/sensor.py @@ -0,0 +1,86 @@ +"""Support for YouTube Sensors.""" +from __future__ import annotations + +from collections.abc import Callable +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 ATTR_ICON +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from . import YouTubeDataUpdateCoordinator +from .const import ( + ATTR_LATEST_VIDEO, + ATTR_SUBSCRIBER_COUNT, + ATTR_THUMBNAIL, + ATTR_TITLE, + COORDINATOR, + DOMAIN, +) +from .entity import YouTubeChannelEntity + + +@dataclass +class YouTubeMixin: + """Mixin for required keys.""" + + value_fn: Callable[[Any], StateType] + entity_picture_fn: Callable[[Any], str] + + +@dataclass +class YouTubeSensorEntityDescription(SensorEntityDescription, YouTubeMixin): + """Describes YouTube sensor entity.""" + + +SENSOR_TYPES = [ + YouTubeSensorEntityDescription( + key="latest_upload", + translation_key="latest_upload", + icon="mdi:youtube", + value_fn=lambda channel: channel[ATTR_LATEST_VIDEO][ATTR_TITLE], + entity_picture_fn=lambda channel: channel[ATTR_LATEST_VIDEO][ATTR_THUMBNAIL], + ), + YouTubeSensorEntityDescription( + key="subscribers", + translation_key="subscribers", + icon="mdi:youtube-subscription", + native_unit_of_measurement="subscribers", + value_fn=lambda channel: channel[ATTR_SUBSCRIBER_COUNT], + entity_picture_fn=lambda channel: channel[ATTR_ICON], + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the YouTube sensor.""" + coordinator: YouTubeDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ + COORDINATOR + ] + async_add_entities( + YouTubeSensor(coordinator, sensor_type, channel) + for channel in coordinator.data.values() + for sensor_type in SENSOR_TYPES + ) + + +class YouTubeSensor(YouTubeChannelEntity, SensorEntity): + """Representation of a YouTube sensor.""" + + entity_description: YouTubeSensorEntityDescription + + @property + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" + return self.entity_description.value_fn(self._channel) + + @property + def entity_picture(self) -> str: + """Return the value reported by the sensor.""" + return self.entity_description.entity_picture_fn(self._channel) diff --git a/homeassistant/components/youtube/strings.json b/homeassistant/components/youtube/strings.json new file mode 100644 index 000000000000..24369ab26f97 --- /dev/null +++ b/homeassistant/components/youtube/strings.json @@ -0,0 +1,42 @@ +{ + "config": { + "abort": { + "access_not_configured": "Please read the below message we got from Google:\n\n{message}", + "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%]" + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "step": { + "channels": { + "description": "Select the channels you want to add.", + "data": { + "channels": "YouTube channels" + } + } + } + }, + "options": { + "step": { + "init": { + "description": "Select the channels you want to add.", + "data": { + "channels": "YouTube channels" + } + } + } + }, + "entity": { + "sensor": { + "latest_upload": { + "name": "Latest upload" + }, + "subscribers": { + "name": "Subscribers" + } + } + } +} diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index 59e76a9c8aeb..d1b330b5dbe7 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -20,4 +20,5 @@ APPLICATION_CREDENTIALS = [ "withings", "xbox", "yolink", + "youtube", ] diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index ad445c6e7395..ca81e7befaf8 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -522,6 +522,7 @@ FLOWS = { "yeelight", "yolink", "youless", + "youtube", "zamg", "zerproc", "zeversolar", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 76a0f69c0888..c91d7f888c39 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2116,6 +2116,12 @@ "config_flow": true, "iot_class": "cloud_push", "name": "Dialogflow" + }, + "youtube": { + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling", + "name": "YouTube" } } }, diff --git a/requirements_all.txt b/requirements_all.txt index 6e3e9cd6eaec..a508ca89bfb1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -811,6 +811,7 @@ goalzero==0.2.1 goodwe==0.2.31 # homeassistant.components.google_mail +# homeassistant.components.youtube google-api-python-client==2.71.0 # homeassistant.components.google_pubsub diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ce06896f2227..09910f00c7dd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -633,6 +633,7 @@ goalzero==0.2.1 goodwe==0.2.31 # homeassistant.components.google_mail +# homeassistant.components.youtube google-api-python-client==2.71.0 # homeassistant.components.google_pubsub diff --git a/tests/components/youtube/__init__.py b/tests/components/youtube/__init__.py new file mode 100644 index 000000000000..289a5e8793ff --- /dev/null +++ b/tests/components/youtube/__init__.py @@ -0,0 +1,68 @@ +"""Tests for the YouTube integration.""" +from dataclasses import dataclass +import json +from typing import Any + +from tests.common import load_fixture + + +@dataclass +class MockRequest: + """Mock object for a request.""" + + fixture: str + + def execute(self) -> dict[str, Any]: + """Return a fixture.""" + return json.loads(load_fixture(self.fixture)) + + +class MockChannels: + """Mock object for channels.""" + + def list( + self, + part: str, + id: str | None = None, + mine: bool | None = None, + maxResults: int | None = None, + ) -> MockRequest: + """Return a fixture.""" + return MockRequest(fixture="youtube/get_channel.json") + + +class MockPlaylistItems: + """Mock object for playlist items.""" + + def list( + self, + part: str, + playlistId: str, + maxResults: int | None = None, + ) -> MockRequest: + """Return a fixture.""" + return MockRequest(fixture="youtube/get_playlist_items.json") + + +class MockSubscriptions: + """Mock object for subscriptions.""" + + def list(self, part: str, mine: bool, maxResults: int | None = None) -> MockRequest: + """Return a fixture.""" + return MockRequest(fixture="youtube/get_subscriptions.json") + + +class MockService: + """Service which returns mock objects.""" + + def channels(self) -> MockChannels: + """Return a mock object.""" + return MockChannels() + + def playlistItems(self) -> MockPlaylistItems: + """Return a mock object.""" + return MockPlaylistItems() + + def subscriptions(self) -> MockSubscriptions: + """Return a mock object.""" + return MockSubscriptions() diff --git a/tests/components/youtube/conftest.py b/tests/components/youtube/conftest.py new file mode 100644 index 000000000000..6513c359a7c9 --- /dev/null +++ b/tests/components/youtube/conftest.py @@ -0,0 +1,116 @@ +"""Configure tests for the Google Mail integration.""" +from collections.abc import Awaitable, Callable, Coroutine +import time +from typing import Any +from unittest.mock import patch + +import pytest + +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.youtube.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry +from tests.components.youtube import MockService +from tests.test_util.aiohttp import AiohttpClientMocker + +ComponentSetup = Callable[[], Awaitable[None]] + +BUILD = "homeassistant.components.google_mail.api.build" +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" +GOOGLE_AUTH_URI = "https://accounts.google.com/o/oauth2/v2/auth" +GOOGLE_TOKEN_URI = "https://oauth2.googleapis.com/token" +SCOPES = [ + "https://www.googleapis.com/auth/youtube.readonly", +] +SENSOR = "sensor.example_gmail_com_vacation_end_date" +TITLE = "Google for Developers" +TOKEN = "homeassistant.components.youtube.api.config_entry_oauth2_flow.OAuth2Session.async_ensure_token_valid" + + +@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 Google Mail entry in Home Assistant.""" + return MockConfigEntry( + domain=DOMAIN, + title=TITLE, + unique_id="UC_x5XG1OV2P6uZZ5FSM9Ttw", + data={ + "auth_implementation": DOMAIN, + "token": { + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "expires_at": expires_at, + "scope": " ".join(scopes), + }, + }, + options={"channels": ["UC_x5XG1OV2P6uZZ5FSM9Ttw"]}, + ) + + +@pytest.fixture(autouse=True) +def mock_connection(aioclient_mock: AiohttpClientMocker) -> None: + """Mock Google Mail connection.""" + aioclient_mock.post( + GOOGLE_TOKEN_URI, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + +@pytest.fixture(name="setup_integration") +async def mock_setup_integration( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> Callable[[], Coroutine[Any, Any, None]]: + """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, + ) + + async def func() -> None: + with patch( + "homeassistant.components.youtube.api.build", return_value=MockService() + ): + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + return func diff --git a/tests/components/youtube/fixtures/get_channel.json b/tests/components/youtube/fixtures/get_channel.json new file mode 100644 index 000000000000..6ab17e119df1 --- /dev/null +++ b/tests/components/youtube/fixtures/get_channel.json @@ -0,0 +1,55 @@ +{ + "kind": "youtube#channelListResponse", + "etag": "8HTiiXpKCq-GJvDVOd88e5o_KGc", + "pageInfo": { + "totalResults": 1, + "resultsPerPage": 5 + }, + "items": [ + { + "kind": "youtube#channel", + "etag": "CG3vwkqpnD2Bj_MaPXmy9puO4Kc", + "id": "UC_x5XG1OV2P6uZZ5FSM9Ttw", + "snippet": { + "title": "Google for Developers", + "description": "Subscribe to join a community of creative developers and learn the latest in Google technology — from AI and cloud, to mobile and web.\n\nExplore more at developers.google.com\n\n", + "customUrl": "@googledevelopers", + "publishedAt": "2007-08-23T00:34:43Z", + "thumbnails": { + "default": { + "url": "https://yt3.ggpht.com/fca_HuJ99xUxflWdex0XViC3NfctBFreIl8y4i9z411asnGTWY-Ql3MeH_ybA4kNaOjY7kyA=s88-c-k-c0x00ffffff-no-rj", + "width": 88, + "height": 88 + }, + "medium": { + "url": "https://yt3.ggpht.com/fca_HuJ99xUxflWdex0XViC3NfctBFreIl8y4i9z411asnGTWY-Ql3MeH_ybA4kNaOjY7kyA=s240-c-k-c0x00ffffff-no-rj", + "width": 240, + "height": 240 + }, + "high": { + "url": "https://yt3.ggpht.com/fca_HuJ99xUxflWdex0XViC3NfctBFreIl8y4i9z411asnGTWY-Ql3MeH_ybA4kNaOjY7kyA=s800-c-k-c0x00ffffff-no-rj", + "width": 800, + "height": 800 + } + }, + "localized": { + "title": "Google for Developers", + "description": "Subscribe to join a community of creative developers and learn the latest in Google technology — from AI and cloud, to mobile and web.\n\nExplore more at developers.google.com\n\n" + }, + "country": "US" + }, + "contentDetails": { + "relatedPlaylists": { + "likes": "", + "uploads": "UU_x5XG1OV2P6uZZ5FSM9Ttw" + } + }, + "statistics": { + "viewCount": "214141263", + "subscriberCount": "2290000", + "hiddenSubscriberCount": false, + "videoCount": "5798" + } + } + ] +} diff --git a/tests/components/youtube/fixtures/get_channel_2.json b/tests/components/youtube/fixtures/get_channel_2.json new file mode 100644 index 000000000000..81da13f7d19c --- /dev/null +++ b/tests/components/youtube/fixtures/get_channel_2.json @@ -0,0 +1,42 @@ +{ + "kind": "youtube#SubscriptionListResponse", + "etag": "6C9iFE7CzKQqPrEoJlE0H2U27xI", + "nextPageToken": "CAEQAA", + "pageInfo": { + "totalResults": 525, + "resultsPerPage": 1 + }, + "items": [ + { + "kind": "youtube#subscription", + "etag": "4Hr8w5f03mLak3fZID0aXypQRDg", + "id": "l6YW-siEBx2rtBlTJ_ip10UA2t_d09UYkgtJsqbYblE", + "snippet": { + "publishedAt": "2015-08-09T21:37:44Z", + "title": "Linus Tech Tips", + "description": "Linus Tech Tips is a passionate team of \"professionally curious\" experts in consumer technology and video production who aim to educate and entertain.", + "resourceId": { + "kind": "youtube#channel", + "channelId": "UCXuqSBlHAE6Xw-yeJA0Tunw" + }, + "channelId": "UCXuqSBlHAE6Xw-yeJA0Tunw", + "thumbnails": { + "default": { + "url": "https://yt3.ggpht.com/Vy6KL7EM_apxPSxF0pPy5w_c87YDTOlBQo3MADDF0Wl51kwxmt9wmRotnt2xQXwlrcyO0Xe56w=s88-c-k-c0x00ffffff-no-rj" + }, + "medium": { + "url": "https://yt3.ggpht.com/Vy6KL7EM_apxPSxF0pPy5w_c87YDTOlBQo3MADDF0Wl51kwxmt9wmRotnt2xQXwlrcyO0Xe56w=s240-c-k-c0x00ffffff-no-rj" + }, + "high": { + "url": "https://yt3.ggpht.com/Vy6KL7EM_apxPSxF0pPy5w_c87YDTOlBQo3MADDF0Wl51kwxmt9wmRotnt2xQXwlrcyO0Xe56w=s800-c-k-c0x00ffffff-no-rj" + } + } + }, + "contentDetails": { + "totalItemCount": 6178, + "newItemCount": 0, + "activityType": "all" + } + } + ] +} diff --git a/tests/components/youtube/fixtures/get_playlist_items.json b/tests/components/youtube/fixtures/get_playlist_items.json new file mode 100644 index 000000000000..790c606cfa70 --- /dev/null +++ b/tests/components/youtube/fixtures/get_playlist_items.json @@ -0,0 +1,266 @@ +{ + "kind": "youtube#playlistItemListResponse", + "etag": "O0Ah8Wd5pUD2Gsv-n0A42RDRcX8", + "nextPageToken": "EAAaBlBUOkNBVQ", + "items": [ + { + "kind": "youtube#playlistItem", + "etag": "qgpoAJRNskzLhD99njC8e2kPB0M", + "id": "VVVfeDVYRzFPVjJQNnVaWjVGU005VHR3Lnd5c3VrRHJNZHFV", + "snippet": { + "publishedAt": "2023-05-11T00:20:46Z", + "channelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw", + "title": "What's new in Google Home in less than 1 minute", + "description": "Discover how your connected devices can do more with Google Home using Matter and Automations at Google I/O 2023.\n\nTo learn more about what's new in Google Home, check out the keynote → https://goo.gle/IO23_homekey\n\nSubscribe to Google Developers → https://goo.gle/developers \n\n#GoogleIO #GoogleHome", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/wysukDrMdqU/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/wysukDrMdqU/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/wysukDrMdqU/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/wysukDrMdqU/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/wysukDrMdqU/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "Google for Developers", + "playlistId": "UU_x5XG1OV2P6uZZ5FSM9Ttw", + "position": 0, + "resourceId": { + "kind": "youtube#video", + "videoId": "wysukDrMdqU" + }, + "videoOwnerChannelTitle": "Google for Developers", + "videoOwnerChannelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw" + }, + "contentDetails": { + "videoId": "wysukDrMdqU", + "videoPublishedAt": "2023-05-11T00:20:46Z" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "pU0v49jXONlQfIJEX7ldINttRYM", + "id": "VVVfeDVYRzFPVjJQNnVaWjVGU005VHR3LmhsZUxsY0h3UUxN", + "snippet": { + "publishedAt": "2023-05-10T22:30:48Z", + "channelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw", + "title": "Google I/O 2023 Developer Keynote in 5 minutes", + "description": "Discover what’s new from Google, including top takeaways and highlights announced at Google I/O 2023. From deep investments in the largest mobile platform, to breakthroughs in AI, learn about the latest capabilities in mobile, web, Cloud, AI, and more. \n\nCatch the full Developer Keynote →https://goo.gle/dev-keynote-23 \nWatch all the Keynotes from Google I/O 2023→ https://goo.gle/IO23_keynotes\nWatch all the Google I/O 2023 Sessions → https://goo.gle/IO23_all \n\n0:00 - Welcome\n0:25 - MakerSuite\n0:49 - Android Studio Bot\n1:38 - Large screens\n2:04 - Wear OS\n2:34 - WebGPU\n2:58 - Baseline\n3:27 - MediaPipe\n3:57 - Duet AI for Google Cloud\n4:59 - Closing\n\nSubscribe to Google Developers → https://goo.gle/developers\n\n#GoogleIO #developers", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/hleLlcHwQLM/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/hleLlcHwQLM/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/hleLlcHwQLM/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/hleLlcHwQLM/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/hleLlcHwQLM/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "Google for Developers", + "playlistId": "UU_x5XG1OV2P6uZZ5FSM9Ttw", + "position": 1, + "resourceId": { + "kind": "youtube#video", + "videoId": "hleLlcHwQLM" + }, + "videoOwnerChannelTitle": "Google for Developers", + "videoOwnerChannelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw" + }, + "contentDetails": { + "videoId": "hleLlcHwQLM", + "videoPublishedAt": "2023-05-10T22:30:48Z" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "fht9mKDuIBXcO75k21ZB_gC_4vM", + "id": "VVVfeDVYRzFPVjJQNnVaWjVGU005VHR3LmxNS2p0U0Z1amN3", + "snippet": { + "publishedAt": "2023-05-10T21:25:47Z", + "channelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw", + "title": "What's new in Google Pay and Wallet in less than 1 minute", + "description": "A quick recap on the latest updates to Google Pay and Wallet from Google I/O 2023.\n\nTo learn more about what's new in Google Pay and Wallet, check out the keynote → https://goo.gle/IO23_paywallet\n\nSubscribe to Google Developers → https://goo.gle/developers\n\n#GoogleIO", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/lMKjtSFujcw/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/lMKjtSFujcw/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/lMKjtSFujcw/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/lMKjtSFujcw/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/lMKjtSFujcw/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "Google for Developers", + "playlistId": "UU_x5XG1OV2P6uZZ5FSM9Ttw", + "position": 2, + "resourceId": { + "kind": "youtube#video", + "videoId": "lMKjtSFujcw" + }, + "videoOwnerChannelTitle": "Google for Developers", + "videoOwnerChannelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw" + }, + "contentDetails": { + "videoId": "lMKjtSFujcw", + "videoPublishedAt": "2023-05-10T21:25:47Z" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "nYKXoKd8eePAZ_xFa3dL5ZmvM5c", + "id": "VVVfeDVYRzFPVjJQNnVaWjVGU005VHR3LmMwbXFCdVhQcnBB", + "snippet": { + "publishedAt": "2023-05-10T20:47:57Z", + "channelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw", + "title": "Developers guide to BigQuery export for Google Analytics 4", + "description": "With Google Analytics 4 (GA4), anyone can set up export of granular measurement data to BigQuery.\n\nIn this session, you will learn how to use the BigQuery export for solving business problems, doing complex reporting, implementing advanced use cases with ML models, and creating custom audiences by joining with first-party data. You can use this framework for detailed or large-scale data analysis. We will also share some best practices to get you started.\n\nResources:\nDevelopers guide to BigQuery export for Google Analytics 4 → https://goo.gle/ga-io23\n\nSpeaker: Minhaz Kazi\n\nWatch more:\nWatch all the Technical Sessions from Google I/O 2023 → https://goo.gle/IO23_sessions\nWatch more Mobile Sessions → https://goo.gle/IO23_mobile\nWatch more Web Sessions → https://goo.gle/IO23_web\nAll Google I/O 2023 Sessions → https://goo.gle/IO23_all\n\nSubscribe to Google Developers → https://goo.gle/developers\n\n#GoogleIO", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/c0mqBuXPrpA/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/c0mqBuXPrpA/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/c0mqBuXPrpA/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/c0mqBuXPrpA/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/c0mqBuXPrpA/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "Google for Developers", + "playlistId": "UU_x5XG1OV2P6uZZ5FSM9Ttw", + "position": 3, + "resourceId": { + "kind": "youtube#video", + "videoId": "c0mqBuXPrpA" + }, + "videoOwnerChannelTitle": "Google for Developers", + "videoOwnerChannelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw" + }, + "contentDetails": { + "videoId": "c0mqBuXPrpA", + "videoPublishedAt": "2023-05-10T20:47:57Z" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "--gb8pSHDwp9c-fyjhZ0K2DklLE", + "id": "VVVfeDVYRzFPVjJQNnVaWjVGU005VHR3Ll9uOXh3dVRPUmFz", + "snippet": { + "publishedAt": "2023-05-10T20:46:29Z", + "channelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw", + "title": "What's new in Google Home - American Sign Language", + "description": "To watch this Session without American Sign Language (ASL) interpretation, please click here → https://goo.gle/IO23_homekey\n\nDiscover how your connected devices can do more with Google Home using Matter and Automations.\n\nResources:\nGoogle Home Developer Center → https://goo.gle/3KcD5xr\n\nDiscover how your connected devices can do more with Google Home using Matter and Automations\nGoogle Home APIs Developer Preview → https://goo.gle/3UakRl0\nAutomations Developer Preview → https://goo.gle/3KgEcMy\n\nSpeakers: Taylor Lehman, Indu Ramamurthi\n\nWatch more:\nWatch more Mobile Sessions → https://goo.gle/IO23_mobile\nAll Google I/O 2023 Sessions → https://goo.gle/IO23_all\n\nSubscribe to Google Developers → https://goo.gle/developers\n\n#GoogleIO", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/_n9xwuTORas/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/_n9xwuTORas/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/_n9xwuTORas/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/_n9xwuTORas/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/_n9xwuTORas/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "Google for Developers", + "playlistId": "UU_x5XG1OV2P6uZZ5FSM9Ttw", + "position": 4, + "resourceId": { + "kind": "youtube#video", + "videoId": "_n9xwuTORas" + }, + "videoOwnerChannelTitle": "Google for Developers", + "videoOwnerChannelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw" + }, + "contentDetails": { + "videoId": "_n9xwuTORas", + "videoPublishedAt": "2023-05-10T20:46:29Z" + } + } + ], + "pageInfo": { + "totalResults": 5798, + "resultsPerPage": 5 + } +} diff --git a/tests/components/youtube/fixtures/get_subscriptions.json b/tests/components/youtube/fixtures/get_subscriptions.json new file mode 100644 index 000000000000..adc605adcfe2 --- /dev/null +++ b/tests/components/youtube/fixtures/get_subscriptions.json @@ -0,0 +1,37 @@ +{ + "kind": "youtube#SubscriptionListResponse", + "etag": "6C9iFE7CzKQqPrEoJlE0H2U27xI", + "nextPageToken": "CAEQAA", + "pageInfo": { + "totalResults": 525, + "resultsPerPage": 1 + }, + "items": [ + { + "kind": "youtube#subscription", + "etag": "4Hr8w5f03mLak3fZID0aXypQRDg", + "id": "l6YW-siEBx2rtBlTJ_ip10UA2t_d09UYkgtJsqbYblE", + "snippet": { + "publishedAt": "2015-08-09T21:37:44Z", + "title": "Linus Tech Tips", + "description": "Linus Tech Tips is a passionate team of \"professionally curious\" experts in consumer technology and video production who aim to educate and entertain.", + "resourceId": { + "kind": "youtube#channel", + "channelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw" + }, + "channelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw", + "thumbnails": { + "default": { + "url": "https://yt3.ggpht.com/Vy6KL7EM_apxPSxF0pPy5w_c87YDTOlBQo3MADDF0Wl51kwxmt9wmRotnt2xQXwlrcyO0Xe56w=s88-c-k-c0x00ffffff-no-rj" + }, + "medium": { + "url": "https://yt3.ggpht.com/Vy6KL7EM_apxPSxF0pPy5w_c87YDTOlBQo3MADDF0Wl51kwxmt9wmRotnt2xQXwlrcyO0Xe56w=s240-c-k-c0x00ffffff-no-rj" + }, + "high": { + "url": "https://yt3.ggpht.com/Vy6KL7EM_apxPSxF0pPy5w_c87YDTOlBQo3MADDF0Wl51kwxmt9wmRotnt2xQXwlrcyO0Xe56w=s800-c-k-c0x00ffffff-no-rj" + } + } + } + } + ] +} diff --git a/tests/components/youtube/test_config_flow.py b/tests/components/youtube/test_config_flow.py new file mode 100644 index 000000000000..006756959834 --- /dev/null +++ b/tests/components/youtube/test_config_flow.py @@ -0,0 +1,171 @@ +"""Test the YouTube config flow.""" +from unittest.mock import patch + +from googleapiclient.errors import HttpError +from httplib2 import Response + +from homeassistant import config_entries +from homeassistant.components.youtube.const import CONF_CHANNELS, DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_entry_oauth2_flow + +from . import MockService +from .conftest import CLIENT_ID, GOOGLE_AUTH_URI, SCOPES, TITLE + +from tests.typing import ClientSessionGenerator + + +async def test_full_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + current_request_with_host: None, +) -> None: + """Check full flow.""" + result = await hass.config_entries.flow.async_init( + "youtube", context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope={'+'.join(SCOPES)}" + "&access_type=offline&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + with patch( + "homeassistant.components.youtube.async_setup_entry", return_value=True + ) as mock_setup, patch( + "homeassistant.components.youtube.api.build", return_value=MockService() + ), patch( + "homeassistant.components.youtube.config_flow.build", return_value=MockService() + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "channels" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_CHANNELS: ["UC_x5XG1OV2P6uZZ5FSM9Ttw"]} + ) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup.mock_calls) == 1 + + assert result["type"] == "create_entry" + assert result["title"] == TITLE + assert "result" in result + assert result["result"].unique_id == "UC_x5XG1OV2P6uZZ5FSM9Ttw" + 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["options"] == {CONF_CHANNELS: ["UC_x5XG1OV2P6uZZ5FSM9Ttw"]} + + +async def test_flow_http_error( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + current_request_with_host: None, +) -> None: + """Check full flow.""" + result = await hass.config_entries.flow.async_init( + "youtube", context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope={'+'.join(SCOPES)}" + "&access_type=offline&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + with patch( + "homeassistant.components.youtube.config_flow.build", + side_effect=HttpError( + Response( + { + "vary": "Origin, X-Origin, Referer", + "content-type": "application/json; charset=UTF-8", + "date": "Mon, 15 May 2023 21:25:42 GMT", + "server": "scaffolding on HTTPServer2", + "cache-control": "private", + "x-xss-protection": "0", + "x-frame-options": "SAMEORIGIN", + "x-content-type-options": "nosniff", + "alt-svc": 'h3=":443"; ma=2592000,h3-29=":443"; ma=2592000', + "transfer-encoding": "chunked", + "status": "403", + "content-length": "947", + "-content-encoding": "gzip", + } + ), + b'{"error": {"code": 403,"message": "YouTube Data API v3 has not been used in project 0 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/youtube.googleapis.com/overview?project=0 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry.","errors": [ { "message": "YouTube Data API v3 has not been used in project 0 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/youtube.googleapis.com/overview?project=0 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry.", "domain": "usageLimits", "reason": "accessNotConfigured", "extendedHelp": "https://console.developers.google.com" }],"status": "PERMISSION_DENIED"\n }\n}\n', + ), + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "access_not_configured" + assert ( + result["description_placeholders"]["message"] + == "YouTube Data API v3 has not been used in project 0 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/youtube.googleapis.com/overview?project=0 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry." + ) + + +async def test_flow_exception( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + current_request_with_host: None, +) -> None: + """Check full flow.""" + result = await hass.config_entries.flow.async_init( + "youtube", context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope={'+'.join(SCOPES)}" + "&access_type=offline&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + with patch( + "homeassistant.components.youtube.config_flow.build", side_effect=Exception + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "unknown" diff --git a/tests/components/youtube/test_init.py b/tests/components/youtube/test_init.py new file mode 100644 index 000000000000..9740d6083af8 --- /dev/null +++ b/tests/components/youtube/test_init.py @@ -0,0 +1,135 @@ +"""Tests for YouTube.""" +import http +import time +from unittest.mock import patch + +from aiohttp.client_exceptions import ClientError +import pytest + +from homeassistant.components.youtube import DOMAIN +from homeassistant.components.youtube.const import CONF_CHANNELS +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from .conftest import GOOGLE_TOKEN_URI, ComponentSetup + +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_setup_success( + hass: HomeAssistant, setup_integration: ComponentSetup +) -> None: + """Test successful setup and unload.""" + await setup_integration() + + 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, + setup_integration: ComponentSetup, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test expired token is refreshed.""" + + aioclient_mock.clear_requests() + aioclient_mock.post( + GOOGLE_TOKEN_URI, + json={ + "access_token": "updated-access-token", + "refresh_token": "updated-refresh-token", + "expires_at": time.time() + 3600, + "expires_in": 3600, + }, + ) + + await setup_integration() + + 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_RETRY, + ), + ( + 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, + setup_integration: ComponentSetup, + aioclient_mock: AiohttpClientMocker, + status: http.HTTPStatus, + expected_state: ConfigEntryState, +) -> None: + """Test failure while refreshing token with a transient error.""" + + aioclient_mock.clear_requests() + aioclient_mock.post( + GOOGLE_TOKEN_URI, + status=status, + ) + + await setup_integration() + + # 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, + setup_integration: ComponentSetup, +) -> None: + """Test failure while refreshing token with a client error.""" + + with patch( + "homeassistant.components.youtube.OAuth2Session.async_ensure_token_valid", + side_effect=ClientError, + ): + await setup_integration() + + # Verify a transient failure has occurred + entries = hass.config_entries.async_entries(DOMAIN) + assert entries[0].state is ConfigEntryState.SETUP_RETRY + + +async def test_device_info( + hass: HomeAssistant, setup_integration: ComponentSetup +) -> None: + """Test device info.""" + await setup_integration() + device_registry = dr.async_get(hass) + + entry = hass.config_entries.async_entries(DOMAIN)[0] + channel_id = entry.options[CONF_CHANNELS][0] + device = device_registry.async_get_device( + {(DOMAIN, f"{entry.entry_id}_{channel_id}")} + ) + + assert device.entry_type is dr.DeviceEntryType.SERVICE + assert device.identifiers == {(DOMAIN, f"{entry.entry_id}_{channel_id}")} + assert device.manufacturer == "Google, Inc." + assert device.name == "Google for Developers" diff --git a/tests/components/youtube/test_sensor.py b/tests/components/youtube/test_sensor.py new file mode 100644 index 000000000000..dfad86cf32e9 --- /dev/null +++ b/tests/components/youtube/test_sensor.py @@ -0,0 +1,29 @@ +"""Sensor tests for the YouTube integration.""" + + +from homeassistant.core import HomeAssistant + +from .conftest import ComponentSetup + + +async def test_sensor(hass: HomeAssistant, setup_integration: ComponentSetup) -> None: + """Test sensor.""" + await setup_integration() + + state = hass.states.get("sensor.google_for_developers_latest_upload") + assert state + assert state.name == "Google for Developers Latest upload" + assert state.state == "What's new in Google Home in less than 1 minute" + assert ( + state.attributes["entity_picture"] + == "https://i.ytimg.com/vi/wysukDrMdqU/sddefault.jpg" + ) + + state = hass.states.get("sensor.google_for_developers_subscribers") + assert state + assert state.name == "Google for Developers Subscribers" + assert state.state == "2290000" + assert ( + state.attributes["entity_picture"] + == "https://yt3.ggpht.com/fca_HuJ99xUxflWdex0XViC3NfctBFreIl8y4i9z411asnGTWY-Ql3MeH_ybA4kNaOjY7kyA=s800-c-k-c0x00ffffff-no-rj" + ) From 94ad9643b5b7eb8a604e3ac26cb1cb474d63df51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Sun, 28 May 2023 01:25:49 +0200 Subject: [PATCH 603/822] Update aioairzone to v0.6.1 (#93629) --- homeassistant/components/airzone/entity.py | 11 ++++ .../components/airzone/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/airzone/test_diagnostics.py | 2 - tests/components/airzone/test_sensor.py | 52 ++++++++++++++++++- 6 files changed, 64 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/airzone/entity.py b/homeassistant/components/airzone/entity.py index a05b8cd6181a..9ee923ba1af9 100644 --- a/homeassistant/components/airzone/entity.py +++ b/homeassistant/components/airzone/entity.py @@ -7,6 +7,7 @@ from typing import Any from aioairzone.const import ( API_SYSTEM_ID, API_ZONE_ID, + AZD_AVAILABLE, AZD_FIRMWARE, AZD_FULL_NAME, AZD_ID, @@ -66,6 +67,11 @@ class AirzoneSystemEntity(AirzoneEntity): ) self._attr_unique_id = entry.unique_id or entry.entry_id + @property + def available(self) -> bool: + """Return system availability.""" + return super().available and self.get_airzone_value(AZD_AVAILABLE) + def get_airzone_value(self, key: str) -> Any: """Return system value by key.""" value = None @@ -130,6 +136,11 @@ class AirzoneZoneEntity(AirzoneEntity): ) self._attr_unique_id = entry.unique_id or entry.entry_id + @property + def available(self) -> bool: + """Return zone availability.""" + return super().available and self.get_airzone_value(AZD_AVAILABLE) + def get_airzone_value(self, key: str) -> Any: """Return zone value by key.""" value = None diff --git a/homeassistant/components/airzone/manifest.json b/homeassistant/components/airzone/manifest.json index d55ffd187fd8..9fbdd95518e6 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.5.6"] + "requirements": ["aioairzone==0.6.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index a508ca89bfb1..54de1740d7ef 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -119,7 +119,7 @@ aioairq==0.2.4 aioairzone-cloud==0.1.6 # homeassistant.components.airzone -aioairzone==0.5.6 +aioairzone==0.6.1 # homeassistant.components.ambient_station aioambient==2023.04.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 09910f00c7dd..61e30b3fb04c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -109,7 +109,7 @@ aioairq==0.2.4 aioairzone-cloud==0.1.6 # homeassistant.components.airzone -aioairzone==0.5.6 +aioairzone==0.6.1 # homeassistant.components.ambient_station aioambient==2023.04.0 diff --git a/tests/components/airzone/test_diagnostics.py b/tests/components/airzone/test_diagnostics.py index 2fa5a7d566a2..33f0175bdb70 100644 --- a/tests/components/airzone/test_diagnostics.py +++ b/tests/components/airzone/test_diagnostics.py @@ -14,7 +14,6 @@ from aioairzone.const import ( AZD_SYSTEM, AZD_SYSTEMS, AZD_ZONES, - AZD_ZONES_NUM, RAW_HVAC, RAW_VERSION, RAW_WEBSERVER, @@ -93,7 +92,6 @@ async def test_config_entry_diagnostics( diag["coord_data"][AZD_SYSTEMS]["1"].items() >= { AZD_ID: 1, - AZD_ZONES_NUM: 5, }.items() ) diff --git a/tests/components/airzone/test_sensor.py b/tests/components/airzone/test_sensor.py index c72c083039e1..1d778761ee18 100644 --- a/tests/components/airzone/test_sensor.py +++ b/tests/components/airzone/test_sensor.py @@ -1,8 +1,23 @@ """The sensor tests for the Airzone platform.""" -from homeassistant.core import HomeAssistant +from unittest.mock import patch -from .util import async_init_integration +from aioairzone.const import API_DATA, API_SYSTEMS + +from homeassistant.components.airzone.coordinator import SCAN_INTERVAL +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.util.dt import utcnow + +from .util import ( + HVAC_MOCK, + HVAC_SYSTEMS_MOCK, + HVAC_VERSION_MOCK, + HVAC_WEBSERVER_MOCK, + async_init_integration, +) + +from tests.common import async_fire_time_changed async def test_airzone_create_sensors( @@ -58,3 +73,36 @@ async def test_airzone_create_sensors( state = hass.states.get("sensor.dkn_plus_humidity") assert state is None + + +async def test_airzone_sensors_availability( + hass: HomeAssistant, entity_registry_enabled_by_default: None +) -> None: + """Test sensors availability.""" + + await async_init_integration(hass) + + HVAC_MOCK_UNAVAILABLE_ZONE = {**HVAC_MOCK} + del HVAC_MOCK_UNAVAILABLE_ZONE[API_SYSTEMS][0][API_DATA][1] + + with patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_hvac", + return_value=HVAC_MOCK_UNAVAILABLE_ZONE, + ), patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_hvac_systems", + return_value=HVAC_SYSTEMS_MOCK, + ), patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_version", + return_value=HVAC_VERSION_MOCK, + ), patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_webserver", + return_value=HVAC_WEBSERVER_MOCK, + ): + async_fire_time_changed(hass, utcnow() + SCAN_INTERVAL) + await hass.async_block_till_done() + + state = hass.states.get("sensor.dorm_ppal_temperature") + assert state.state == STATE_UNAVAILABLE + + state = hass.states.get("sensor.dorm_ppal_humidity") + assert state.state == STATE_UNAVAILABLE From 3a1389c3b4cbd97ae04b38ac65f8eef095ba5bc3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 27 May 2023 18:45:35 -0500 Subject: [PATCH 604/822] Switch some frequently called call_laters to call_at (#93659) * Switch some frequently called call_laters to call_at call_at is a bit faster than call_later since call_later is a wrapper around call_at. We call at lot of these at startup so it helps a bit when we are resource constrained * update test * update test --- homeassistant/helpers/entity.py | 4 ++-- homeassistant/helpers/entity_platform.py | 4 ++-- tests/helpers/test_entity_platform.py | 12 +++++++----- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 001713505941..a1435a572775 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -770,8 +770,8 @@ class Entity(ABC): await self.parallel_updates.acquire() if warning: - update_warn = hass.loop.call_later( - SLOW_UPDATE_WARNING, self._async_slow_update_warning + update_warn = hass.loop.call_at( + hass.loop.time() + SLOW_UPDATE_WARNING, self._async_slow_update_warning ) try: diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 372f541d4bd5..4adb0f212b3d 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -288,8 +288,8 @@ class EntityPlatform: ) logger.info("Setting up %s", full_name) - warn_task = hass.loop.call_later( - SLOW_SETUP_WARNING, + warn_task = hass.loop.call_at( + hass.loop.time() + SLOW_SETUP_WARNING, logger.warning, "Setup of %s platform %s is taking over %s seconds.", self.domain, diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 56872fe5b4b6..e6b864be09cb 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -223,16 +223,18 @@ async def test_platform_warn_slow_setup(hass: HomeAssistant) -> None: component = EntityComponent(_LOGGER, DOMAIN, hass) - with patch.object(hass.loop, "call_later") as mock_call: + with patch.object(hass.loop, "call_at") as mock_call: await component.async_setup({DOMAIN: {"platform": "platform"}}) await hass.async_block_till_done() assert mock_call.called - # mock_calls[0] is the warning message for component setup - # mock_calls[4] is the warning message for platform setup - timeout, logger_method = mock_call.mock_calls[4][1][:2] + # mock_calls[3] is the warning message for component setup + # mock_calls[10] is the warning message for platform setup + timeout, logger_method = mock_call.mock_calls[10][1][:2] - assert timeout == entity_platform.SLOW_SETUP_WARNING + assert timeout - hass.loop.time() == pytest.approx( + entity_platform.SLOW_SETUP_WARNING, 0.5 + ) assert logger_method == _LOGGER.warning assert mock_call().cancel.called From 5a0b25479e53888dcba677957cf5369cb27316d6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 27 May 2023 18:46:46 -0500 Subject: [PATCH 605/822] Use httpx instead of requests for mjpeg camera images that need digest to avoid jump to executor (#93244) * Use httpx instead of requests for mjpeg camera images that need digest Avoids jump to executor * Use httpx instead of requests for mjpeg camera images that need digest Avoids jump to executor * stream as well * fix * fix --- homeassistant/components/mjpeg/__init__.py | 2 +- homeassistant/components/mjpeg/camera.py | 96 ++++++++++++++-------- 2 files changed, 64 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/mjpeg/__init__.py b/homeassistant/components/mjpeg/__init__.py index 605c8b6c9d51..27131d9d18fb 100644 --- a/homeassistant/components/mjpeg/__init__.py +++ b/homeassistant/components/mjpeg/__init__.py @@ -16,7 +16,7 @@ __all__ = [ ] -def setup(hass: HomeAssistant, config: ConfigType) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the MJPEG IP Camera integration.""" filter_urllib3_logging() return True diff --git a/homeassistant/components/mjpeg/camera.py b/homeassistant/components/mjpeg/camera.py index 27f5c7a1411e..c2ab3b5768cf 100644 --- a/homeassistant/components/mjpeg/camera.py +++ b/homeassistant/components/mjpeg/camera.py @@ -2,14 +2,13 @@ from __future__ import annotations import asyncio -from collections.abc import Iterable -from contextlib import closing +from collections.abc import AsyncIterator +from contextlib import suppress import aiohttp from aiohttp import web import async_timeout -import requests -from requests.auth import HTTPBasicAuth, HTTPDigestAuth +import httpx from yarl import URL from homeassistant.components.camera import Camera @@ -29,9 +28,13 @@ from homeassistant.helpers.aiohttp_client import ( ) from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.httpx_client import get_async_client from .const import CONF_MJPEG_URL, CONF_STILL_IMAGE_URL, DOMAIN, LOGGER +TIMEOUT = 10 +BUFFER_SIZE = 102400 + async def async_setup_entry( hass: HomeAssistant, @@ -59,11 +62,11 @@ async def async_setup_entry( ) -def extract_image_from_mjpeg(stream: Iterable[bytes]) -> bytes | None: +async def async_extract_image_from_mjpeg(stream: AsyncIterator[bytes]) -> bytes | None: """Take in a MJPEG stream object, return the jpg from it.""" data = b"" - for chunk in stream: + async for chunk in stream: data += chunk jpg_end = data.find(b"\xff\xd9") @@ -137,12 +140,11 @@ class MjpegCamera(Camera): self._authentication == HTTP_DIGEST_AUTHENTICATION or self._still_image_url is None ): - image = await self.hass.async_add_executor_job(self.camera_image) - return image + return await self._async_digest_camera_image() websession = async_get_clientsession(self.hass, verify_ssl=self._verify_ssl) try: - async with async_timeout.timeout(10): + async with async_timeout.timeout(TIMEOUT): response = await websession.get(self._still_image_url, auth=self._auth) image = await response.read() @@ -156,37 +158,65 @@ class MjpegCamera(Camera): return None - def camera_image( - self, width: int | None = None, height: int | None = None - ) -> bytes | None: - """Return a still image response from the camera.""" - if self._username and self._password: - if self._authentication == HTTP_DIGEST_AUTHENTICATION: - auth: HTTPDigestAuth | HTTPBasicAuth = HTTPDigestAuth( - self._username, self._password - ) - else: - auth = HTTPBasicAuth(self._username, self._password) - req = requests.get( - self._mjpeg_url, - auth=auth, - stream=True, - timeout=10, - verify=self._verify_ssl, - ) - else: - req = requests.get(self._mjpeg_url, stream=True, timeout=10) + def _get_digest_auth(self) -> httpx.DigestAuth: + """Return a DigestAuth object.""" + username = "" if self._username is None else self._username + return httpx.DigestAuth(username, self._password) - with closing(req) as response: - return extract_image_from_mjpeg(response.iter_content(102400)) + async def _async_digest_camera_image(self) -> bytes | None: + """Return a still image response from the camera using digest authentication.""" + client = get_async_client(self.hass, verify_ssl=self._verify_ssl) + auth = self._get_digest_auth() + try: + if self._still_image_url: + # Fallback to MJPEG stream if still image URL is not available + with suppress(asyncio.TimeoutError, httpx.HTTPError): + return ( + await client.get( + self._still_image_url, auth=auth, timeout=TIMEOUT + ) + ).content + + async with client.stream( + "get", self._mjpeg_url, auth=auth, timeout=TIMEOUT + ) as stream: + return await async_extract_image_from_mjpeg( + stream.aiter_bytes(BUFFER_SIZE) + ) + + except asyncio.TimeoutError: + LOGGER.error("Timeout getting camera image from %s", self.name) + + except httpx.HTTPError as err: + LOGGER.error("Error getting new camera image from %s: %s", self.name, err) + + return None + + async def _handle_async_mjpeg_digest_stream( + self, request: web.Request + ) -> web.StreamResponse | None: + """Generate an HTTP MJPEG stream from the camera using digest authentication.""" + async with get_async_client(self.hass, verify_ssl=self._verify_ssl).stream( + "get", self._mjpeg_url, auth=self._get_digest_auth(), timeout=TIMEOUT + ) as stream: + response = web.StreamResponse(headers=stream.headers) + await response.prepare(request) + # Stream until we are done or client disconnects + with suppress(asyncio.TimeoutError, httpx.HTTPError): + async for chunk in stream.aiter_bytes(BUFFER_SIZE): + if not self.hass.is_running: + break + async with async_timeout.timeout(TIMEOUT): + await response.write(chunk) + return response async def handle_async_mjpeg_stream( self, request: web.Request ) -> web.StreamResponse | None: """Generate an HTTP MJPEG stream from the camera.""" - # aiohttp don't support DigestAuth -> Fallback + # aiohttp don't support DigestAuth so we use httpx if self._authentication == HTTP_DIGEST_AUTHENTICATION: - return await super().handle_async_mjpeg_stream(request) + return await self._handle_async_mjpeg_digest_stream(request) # connect to stream websession = async_get_clientsession(self.hass, verify_ssl=self._verify_ssl) From 560e744f1ecd6e97a9c18e379242a6c61c70118e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 27 May 2023 18:52:42 -0500 Subject: [PATCH 606/822] Reduce overhead to filter entities in the recorder (#93656) * Reduce overhead to filter entities in the recorder * fix type --- homeassistant/components/logbook/__init__.py | 2 +- homeassistant/components/logbook/helpers.py | 5 ++--- homeassistant/components/logbook/models.py | 3 +-- homeassistant/components/logbook/rest_api.py | 6 ++--- .../components/logbook/websocket_api.py | 3 +-- homeassistant/components/recorder/__init__.py | 2 +- homeassistant/helpers/entityfilter.py | 22 ++++++++++--------- tests/helpers/test_entityfilter.py | 22 +++++++++++++++++++ 8 files changed, 43 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/logbook/__init__.py b/homeassistant/components/logbook/__init__.py index ee2ae3da4d95..0c614972e1e3 100644 --- a/homeassistant/components/logbook/__init__.py +++ b/homeassistant/components/logbook/__init__.py @@ -127,7 +127,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: possible_merged_entities_filter = convert_include_exclude_filter(merged_filter) if not possible_merged_entities_filter.empty_filter: filters = sqlalchemy_filter_from_include_exclude_conf(merged_filter) - entities_filter = possible_merged_entities_filter + entities_filter = possible_merged_entities_filter.get_filter() else: filters = None entities_filter = None diff --git a/homeassistant/components/logbook/helpers.py b/homeassistant/components/logbook/helpers.py index c8f55331de13..3a1ec971b54e 100644 --- a/homeassistant/components/logbook/helpers.py +++ b/homeassistant/components/logbook/helpers.py @@ -23,7 +23,6 @@ from homeassistant.core import ( split_entity_id, ) from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.entityfilter import EntityFilter from homeassistant.helpers.event import async_track_state_change_event from .const import ALWAYS_CONTINUOUS_DOMAINS, AUTOMATION_EVENTS, BUILT_IN_EVENTS, DOMAIN @@ -104,7 +103,7 @@ def extract_attr(source: dict[str, Any], attr: str) -> list[str]: @callback def event_forwarder_filtered( target: Callable[[Event], None], - entities_filter: EntityFilter | None, + entities_filter: Callable[[str], bool] | None, entity_ids: list[str] | None, device_ids: list[str] | None, ) -> Callable[[Event], None]: @@ -159,7 +158,7 @@ def async_subscribe_events( subscriptions: list[CALLBACK_TYPE], target: Callable[[Event], None], event_types: tuple[str, ...], - entities_filter: EntityFilter | None, + entities_filter: Callable[[str], bool] | None, entity_ids: list[str] | None, device_ids: list[str] | None, ) -> None: diff --git a/homeassistant/components/logbook/models.py b/homeassistant/components/logbook/models.py index 86dcfdf82c51..e351ee6bb613 100644 --- a/homeassistant/components/logbook/models.py +++ b/homeassistant/components/logbook/models.py @@ -16,7 +16,6 @@ from homeassistant.components.recorder.models import ( ) from homeassistant.const import ATTR_ICON, EVENT_STATE_CHANGED from homeassistant.core import Context, Event, State, callback -from homeassistant.helpers.entityfilter import EntityFilter import homeassistant.util.dt as dt_util from homeassistant.util.json import json_loads from homeassistant.util.ulid import ulid_to_bytes @@ -30,7 +29,7 @@ class LogbookConfig: str, tuple[str, Callable[[LazyEventPartialState], dict[str, Any]]] ] sqlalchemy_filter: Filters | None = None - entity_filter: EntityFilter | None = None + entity_filter: Callable[[str], bool] | None = None class LazyEventPartialState: diff --git a/homeassistant/components/logbook/rest_api.py b/homeassistant/components/logbook/rest_api.py index a1a7db3ed2c0..57d0a6695c7d 100644 --- a/homeassistant/components/logbook/rest_api.py +++ b/homeassistant/components/logbook/rest_api.py @@ -1,6 +1,7 @@ """Event parser and human readable log generator.""" from __future__ import annotations +from collections.abc import Callable from datetime import timedelta from http import HTTPStatus from typing import Any, cast @@ -14,7 +15,6 @@ from homeassistant.components.recorder.filters import Filters from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import InvalidEntityFormatError from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entityfilter import EntityFilter from homeassistant.helpers.typing import ConfigType import homeassistant.util.dt as dt_util @@ -27,7 +27,7 @@ def async_setup( hass: HomeAssistant, conf: ConfigType, filters: Filters | None, - entities_filter: EntityFilter | None, + entities_filter: Callable[[str], bool] | None, ) -> None: """Set up the logbook rest API.""" hass.http.register_view(LogbookView(conf, filters, entities_filter)) @@ -44,7 +44,7 @@ class LogbookView(HomeAssistantView): self, config: dict[str, Any], filters: Filters | None, - entities_filter: EntityFilter | None, + entities_filter: Callable[[str], bool] | None, ) -> None: """Initialize the logbook view.""" self.config = config diff --git a/homeassistant/components/logbook/websocket_api.py b/homeassistant/components/logbook/websocket_api.py index c4e6b9814f49..4afa40cb14f1 100644 --- a/homeassistant/components/logbook/websocket_api.py +++ b/homeassistant/components/logbook/websocket_api.py @@ -15,7 +15,6 @@ from homeassistant.components.recorder import get_instance from homeassistant.components.websocket_api import messages from homeassistant.components.websocket_api.connection import ActiveConnection from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback -from homeassistant.helpers.entityfilter import EntityFilter from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.json import JSON_DUMP import homeassistant.util.dt as dt_util @@ -357,7 +356,7 @@ async def ws_event_stream( ) _unsub() - entities_filter: EntityFilter | None = None + entities_filter: Callable[[str], bool] | None = None if not event_processor.limited_select: logbook_config: LogbookConfig = hass.data[DOMAIN] entities_filter = logbook_config.entity_filter diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 7b43abd8dde9..72d825d9e785 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -135,7 +135,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: 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) + entity_filter = convert_include_exclude_filter(conf).get_filter() auto_purge = conf[CONF_AUTO_PURGE] auto_repack = conf[CONF_AUTO_REPACK] keep_days = conf[CONF_PURGE_KEEP_DAYS] diff --git a/homeassistant/helpers/entityfilter.py b/homeassistant/helpers/entityfilter.py index 057e8f0955e3..a9d3ccad138f 100644 --- a/homeassistant/helpers/entityfilter.py +++ b/homeassistant/helpers/entityfilter.py @@ -35,7 +35,14 @@ class EntityFilter: self._exclude_d = set(config[CONF_EXCLUDE_DOMAINS]) self._include_eg = _convert_globs_to_pattern(config[CONF_INCLUDE_ENTITY_GLOBS]) self._exclude_eg = _convert_globs_to_pattern(config[CONF_EXCLUDE_ENTITY_GLOBS]) - self._filter: Callable[[str], bool] | None = None + self._filter = _generate_filter_from_sets_and_pattern_lists( + self._include_d, + self._include_e, + self._exclude_d, + self._exclude_e, + self._include_eg, + self._exclude_eg, + ) def explicitly_included(self, entity_id: str) -> bool: """Check if an entity is explicitly included.""" @@ -49,17 +56,12 @@ class EntityFilter: bool(self._exclude_eg and self._exclude_eg.match(entity_id)) ) + def get_filter(self) -> Callable[[str], bool]: + """Return the filter function.""" + return self._filter + def __call__(self, entity_id: str) -> bool: """Run the filter.""" - if self._filter is None: - self._filter = _generate_filter_from_sets_and_pattern_lists( - self._include_d, - self._include_e, - self._exclude_d, - self._exclude_e, - self._include_eg, - self._exclude_eg, - ) return self._filter(entity_id) diff --git a/tests/helpers/test_entityfilter.py b/tests/helpers/test_entityfilter.py index 2141c2869142..48bc8110ec5d 100644 --- a/tests/helpers/test_entityfilter.py +++ b/tests/helpers/test_entityfilter.py @@ -395,6 +395,28 @@ def test_explicitly_included() -> None: assert filt.explicitly_excluded("light.kitchen") +def test_get_filter() -> None: + """Test we can get the underlying filter.""" + conf = { + "include": { + "domains": ["light"], + "entity_globs": ["sensor.kitchen_*"], + "entities": ["switch.kitchen"], + }, + "exclude": { + "domains": ["cover"], + "entity_globs": ["sensor.weather_*"], + "entities": ["light.kitchen"], + }, + } + filt: EntityFilter = INCLUDE_EXCLUDE_FILTER_SCHEMA(conf) + underlying_filter = filt.get_filter() + assert underlying_filter("light.any") + assert not underlying_filter("switch.other") + assert underlying_filter("sensor.kitchen_4") + assert underlying_filter("switch.kitchen") + + def test_complex_include_exclude_filter() -> None: """Test a complex include exclude filter.""" conf = { From c721cbd10c4a69d2a1c493e86743a698d525f064 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 27 May 2023 18:53:08 -0500 Subject: [PATCH 607/822] Collapse valid state check when creating a state (#93655) Inline valid state check when creating a state This was added in #9696 and only used in one place so it does not need to be another function --- homeassistant/core.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index 747affef76fc..1966045f5692 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -188,11 +188,6 @@ def valid_entity_id(entity_id: str) -> bool: return VALID_ENTITY_ID.match(entity_id) is not None -def valid_state(state: str) -> bool: - """Test if a state is valid.""" - return len(state) <= MAX_LENGTH_STATE_STATE - - def callback(func: _CallableT) -> _CallableT: """Annotation to mark method as safe to call from within the event loop.""" setattr(func, "_hass_callback", True) @@ -1256,7 +1251,7 @@ class State: "Format should be ." ) - if not valid_state(state): + if len(state) > MAX_LENGTH_STATE_STATE: raise InvalidStateError( f"Invalid state encountered for entity ID: {entity_id}. " "State max length is 255 characters." From 5feceee588cbc35a611ec204666525eaa5ce6cef Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 27 May 2023 18:53:52 -0500 Subject: [PATCH 608/822] Improve performance when serializing small bits of JSON (#93653) * Improve performance when serializing small bits of JSON Making json_bytes a partial reduced the run time to build the small JSON messages by ~18.75% We serialize a lot of small messages over the websocket * typing --- homeassistant/helpers/json.py | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/homeassistant/helpers/json.py b/homeassistant/helpers/json.py index 5f8a6894121b..38c230508851 100644 --- a/homeassistant/helpers/json.py +++ b/homeassistant/helpers/json.py @@ -2,10 +2,11 @@ from collections import deque from collections.abc import Callable import datetime +from functools import partial import json import logging from pathlib import Path -from typing import Any, Final +from typing import TYPE_CHECKING, Any, Final import orjson @@ -55,6 +56,18 @@ def json_encoder_default(obj: Any) -> Any: raise TypeError +if TYPE_CHECKING: + + def json_bytes(obj: Any) -> bytes: + """Dump json bytes.""" + +else: + json_bytes = partial( + orjson.dumps, option=orjson.OPT_NON_STR_KEYS, default=json_encoder_default + ) + """Dump json bytes.""" + + class ExtendedJSONEncoder(JSONEncoder): """JSONEncoder that supports Home Assistant objects and falls back to repr(o).""" @@ -75,13 +88,6 @@ class ExtendedJSONEncoder(JSONEncoder): return {"__type": str(type(o)), "repr": repr(o)} -def json_bytes(data: Any) -> bytes: - """Dump json bytes.""" - return orjson.dumps( - data, option=orjson.OPT_NON_STR_KEYS, default=json_encoder_default - ) - - def _strip_null(obj: Any) -> Any: """Strip NUL from an object.""" if isinstance(obj, str): @@ -119,9 +125,7 @@ def json_dumps(data: Any) -> str: with option |= orjson.OPT_PASSTHROUGH_DATACLASS and it will fallback to as_dict """ - return orjson.dumps( - data, option=orjson.OPT_NON_STR_KEYS, default=json_encoder_default - ).decode("utf-8") + return json_bytes(data).decode("utf-8") def json_dumps_sorted(data: Any) -> str: From 460d04fc07e910ba4a8fa2df83acae643a89d3fc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 27 May 2023 18:54:40 -0500 Subject: [PATCH 609/822] Disable polling on energy sensors (#93651) These sensors do not implement async_update so no polling is needed --- homeassistant/components/energy/sensor.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py index b2b29760e5e2..7518b163f3c1 100644 --- a/homeassistant/components/energy/sensor.py +++ b/homeassistant/components/energy/sensor.py @@ -226,6 +226,8 @@ class EnergyCostSensor(SensorEntity): """ _attr_entity_registry_visible_default = False + _attr_should_poll = False + _wrong_state_class_reported = False _wrong_unit_reported = False From a56b5994e5f212e1de781621697b8d9aa5b37e4b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 27 May 2023 18:55:48 -0500 Subject: [PATCH 610/822] Disable polling for samsungtv remote entities (#93650) These entities do not have a defined state, there is no reason to poll --- homeassistant/components/samsungtv/remote.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/samsungtv/remote.py b/homeassistant/components/samsungtv/remote.py index dd9e46dedcb2..22857d966596 100644 --- a/homeassistant/components/samsungtv/remote.py +++ b/homeassistant/components/samsungtv/remote.py @@ -18,12 +18,14 @@ async def async_setup_entry( ) -> None: """Set up the Samsung TV from a config entry.""" bridge = hass.data[DOMAIN][entry.entry_id] - async_add_entities([SamsungTVRemote(bridge=bridge, config_entry=entry)], True) + async_add_entities([SamsungTVRemote(bridge=bridge, config_entry=entry)]) class SamsungTVRemote(SamsungTVEntity, RemoteEntity): """Device that sends commands to a SamsungTV.""" + _attr_should_poll = False + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" await self._bridge.async_power_off() From b966ff7a3d6c4384f226e8e2bb9c9eb00582680a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 27 May 2023 18:58:02 -0500 Subject: [PATCH 611/822] Await in sequence when we know we will be blocked by the entity platform semaphore (#93649) * Defer creating tasks we know are going to wait on the entity platform semaphore When looking at whats going on with aiomonitor-ng, I noticed we end up creating a lot of tasks that block waiting for the executor because of the entity platform parallel_updates semaphore. When we know the tasks are going to block we now await them in sequence to avoid feeding the loop a herd of tasks that will block on the semaphore * change during iteration fix * change during iteration fix * cleanup * cleanup * fix vizio test --- homeassistant/helpers/entity_platform.py | 21 +++++++++++++++------ tests/components/vizio/test_media_player.py | 2 ++ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 4adb0f212b3d..be335c7a40f7 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -136,6 +136,7 @@ class EntityPlatform: self._process_updates: asyncio.Lock | None = None self.parallel_updates: asyncio.Semaphore | None = None + self._update_in_parallel: bool = True # Platform is None for the EntityComponent "catch-all" EntityPlatform # which powers entity_component.add_entities @@ -186,6 +187,7 @@ class EntityPlatform: if parallel_updates is not None: self.parallel_updates = asyncio.Semaphore(parallel_updates) + self._update_in_parallel = parallel_updates != 1 return self.parallel_updates @@ -844,13 +846,20 @@ class EntityPlatform: return async with self._process_updates: - tasks: list[Coroutine[Any, Any, None]] = [] - for entity in self.entities.values(): - if not entity.should_poll: - continue - tasks.append(entity.async_update_ha_state(True)) + if self._update_in_parallel or len(self.entities) <= 1: + # If we know are going to update sequentially, we want to update + # to avoid scheduling the coroutines as tasks that will we know + # are going to wait on the semaphore lock. + for entity in list(self.entities.values()): + if entity.should_poll and entity.hass: + await entity.async_update_ha_state(True) + return - if tasks: + if tasks := [ + entity.async_update_ha_state(True) + for entity in self.entities.values() + if entity.should_poll + ]: await asyncio.gather(*tasks) diff --git a/tests/components/vizio/test_media_player.py b/tests/components/vizio/test_media_player.py index 69e4e4d5b040..86733d83f152 100644 --- a/tests/components/vizio/test_media_player.py +++ b/tests/components/vizio/test_media_player.py @@ -746,6 +746,8 @@ async def test_apps_update( "homeassistant.components.vizio.gen_apps_list_from_url", return_value=APP_LIST, ): + async_fire_time_changed(hass, dt_util.now() + timedelta(days=2)) + await hass.async_block_till_done() async_fire_time_changed(hass, dt_util.now() + timedelta(days=2)) await hass.async_block_till_done() # Check source list, remove TV inputs, and verify that the integration is From 67d9fa8b2209238b61eb92e52d29f54434124595 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 27 May 2023 18:58:27 -0500 Subject: [PATCH 612/822] Disable call_soon for device tracker device registry updates (#93638) Run device tracker device registry updates immediately without the call_soon delay. This listener does not need a delay and it only adds overhead in this case. --- homeassistant/components/device_tracker/config_entry.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/device_tracker/config_entry.py b/homeassistant/components/device_tracker/config_entry.py index 5d56548f0ecb..c4450ab60a74 100644 --- a/homeassistant/components/device_tracker/config_entry.py +++ b/homeassistant/components/device_tracker/config_entry.py @@ -176,7 +176,9 @@ def _async_register_mac( # Enable entity ent_reg.async_update_entity(entity_id, disabled_by=None) - hass.bus.async_listen(dr.EVENT_DEVICE_REGISTRY_UPDATED, handle_device_event) + hass.bus.async_listen( + dr.EVENT_DEVICE_REGISTRY_UPDATED, handle_device_event, run_immediately=True + ) class BaseTrackerEntity(Entity): From e1b7d681349414df21df73a3c494a0b22d082eb1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 27 May 2023 18:59:46 -0500 Subject: [PATCH 613/822] Speed up processing subscribe_events and subscribe_entites when user has read all permissions (#93611) Speed up processing subscribe_events and subscribe_entites when user the read all permissions --- .../components/websocket_api/commands.py | 35 +++++++++++-------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index b5dabf8b7339..bdb087069f83 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -109,15 +109,18 @@ def handle_subscribe_events( raise Unauthorized if event_type == EVENT_STATE_CHANGED: + user = connection.user @callback def forward_events(event: Event) -> None: """Forward state changed events to websocket.""" - if not connection.user.permissions.check_entity( - event.data["entity_id"], POLICY_READ - ): + # We have to lookup the permissions again because the user might have + # changed since the subscription was created. + permissions = user.permissions + if not permissions.access_all_entities( + POLICY_READ + ) and not permissions.check_entity(event.data["entity_id"], POLICY_READ): return - connection.send_message(messages.cached_event_message(msg["id"], event)) else: @@ -227,13 +230,13 @@ async def handle_call_service( def _async_get_allowed_states( hass: HomeAssistant, connection: ActiveConnection ) -> list[State]: - if connection.user.permissions.access_all_entities("read"): + if connection.user.permissions.access_all_entities(POLICY_READ): return hass.states.async_all() entity_perm = connection.user.permissions.check_entity return [ state for state in hass.states.async_all() - if entity_perm(state.entity_id, "read") + if entity_perm(state.entity_id, POLICY_READ) ] @@ -289,17 +292,21 @@ def handle_subscribe_entities( ) -> None: """Handle subscribe entities command.""" entity_ids = set(msg.get("entity_ids", [])) + user = connection.user @callback def forward_entity_changes(event: Event) -> None: """Forward entity state changed events to websocket.""" - if not connection.user.permissions.check_entity( - event.data["entity_id"], POLICY_READ - ): + entity_id = event.data["entity_id"] + if entity_ids and entity_id not in entity_ids: return - if entity_ids and event.data["entity_id"] not in entity_ids: + # We have to lookup the permissions again because the user might have + # changed since the subscription was created. + permissions = user.permissions + if not permissions.access_all_entities( + POLICY_READ + ) and not permissions.check_entity(event.data["entity_id"], POLICY_READ): return - connection.send_message(messages.cached_state_diff_message(msg["id"], event)) # We must never await between sending the states and listening for @@ -541,13 +548,13 @@ def handle_entity_source( entity_perm = connection.user.permissions.check_entity if "entity_id" not in msg: - if connection.user.permissions.access_all_entities("read"): + 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, "read") + if entity_perm(entity_id, POLICY_READ) } connection.send_result(msg["id"], sources) @@ -556,7 +563,7 @@ def handle_entity_source( sources = {} for entity_id in msg["entity_id"]: - if not entity_perm(entity_id, "read"): + if not entity_perm(entity_id, POLICY_READ): raise Unauthorized( context=connection.context(msg), permission=POLICY_READ, From 0a9c4f15c4d8a0d3e030450fed44b748604dfc70 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 27 May 2023 19:04:09 -0500 Subject: [PATCH 614/822] Add event helper to dispatch device registry updates by device_id (#93602) * Add event helper to dispatch device registry updates by device_id * Update homeassistant/helpers/event.py * dry * dry * reduce * reduce * reorder * reduce * cleanup stack depth * dry * fix double lookup * remove unused * collapse --- homeassistant/helpers/event.py | 420 +++++++++++++++++---------------- tests/helpers/test_event.py | 102 ++++++++ 2 files changed, 315 insertions(+), 207 deletions(-) diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 4c06b0f23e05..c251f0785a4d 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -36,6 +36,7 @@ from homeassistant.loader import bind_hass from homeassistant.util import dt as dt_util from homeassistant.util.async_ import run_callback_threadsafe +from .device_registry import EVENT_DEVICE_REGISTRY_UPDATED from .entity_registry import EVENT_ENTITY_REGISTRY_UPDATED from .ratelimit import KeyedRateLimit from .sun import get_astral_event_next @@ -54,6 +55,9 @@ TRACK_STATE_REMOVED_DOMAIN_LISTENER = "track_state_removed_domain_listener" TRACK_ENTITY_REGISTRY_UPDATED_CALLBACKS = "track_entity_registry_updated_callbacks" TRACK_ENTITY_REGISTRY_UPDATED_LISTENER = "track_entity_registry_updated_listener" +TRACK_DEVICE_REGISTRY_UPDATED_CALLBACKS = "track_device_registry_updated_callbacks" +TRACK_DEVICE_REGISTRY_UPDATED_LISTENER = "track_device_registry_updated_listener" + _ALL_LISTENER = "all" _DOMAINS_LISTENER = "domains" _ENTITIES_LISTENER = "entities" @@ -256,6 +260,34 @@ def async_track_state_change_event( return _async_track_state_change_event(hass, entity_ids, action) +@callback +def _async_dispatch_entity_id_event( + hass: HomeAssistant, + callbacks: dict[str, list[HassJob[[Event], Any]]], + event: Event, +) -> None: + """Dispatch to listeners.""" + if not (callbacks_list := callbacks.get(event.data["entity_id"])): + return + for job in callbacks_list[:]: + try: + hass.async_run_hass_job(job, event) + except Exception: # pylint: disable=broad-except + _LOGGER.exception( + "Error while dispatching event for %s to %s", + event.data["entity_id"], + job, + ) + + +@callback +def _async_state_change_filter( + hass: HomeAssistant, callbacks: dict[str, list[HassJob[[Event], Any]]], event: Event +) -> bool: + """Filter state changes by entity_id.""" + return event.data["entity_id"] in callbacks + + @bind_hass def _async_track_state_change_event( hass: HomeAssistant, @@ -263,82 +295,105 @@ def _async_track_state_change_event( action: Callable[[Event], Any], ) -> CALLBACK_TYPE: """async_track_state_change_event without lowercasing.""" - entity_callbacks: dict[str, list[HassJob[[Event], Any]]] = hass.data.setdefault( - TRACK_STATE_CHANGE_CALLBACKS, {} + return _async_track_event( + hass, + entity_ids, + TRACK_STATE_CHANGE_CALLBACKS, + TRACK_STATE_CHANGE_LISTENER, + EVENT_STATE_CHANGED, + _async_dispatch_entity_id_event, + _async_state_change_filter, + action, ) - if TRACK_STATE_CHANGE_LISTENER not in hass.data: - - @callback - def _async_state_change_filter(event: Event) -> bool: - """Filter state changes by entity_id.""" - return event.data.get("entity_id") in entity_callbacks - - @callback - def _async_state_change_dispatcher(event: Event) -> None: - """Dispatch state changes by entity_id.""" - entity_id = event.data.get("entity_id") - - if entity_id not in entity_callbacks: - return - - for job in entity_callbacks[entity_id][:]: - try: - hass.async_run_hass_job(job, event) - except Exception: # pylint: disable=broad-except - _LOGGER.exception( - "Error while processing state change for %s", entity_id - ) - - hass.data[TRACK_STATE_CHANGE_LISTENER] = hass.bus.async_listen( - EVENT_STATE_CHANGED, - _async_state_change_dispatcher, - event_filter=_async_state_change_filter, - ) - - job = HassJob(action, f"track state change event {entity_ids}") - - for entity_id in entity_ids: - entity_callbacks.setdefault(entity_id, []).append(job) - - @callback - def remove_listener() -> None: - """Remove state change listener.""" - _async_remove_indexed_listeners( - hass, - TRACK_STATE_CHANGE_CALLBACKS, - TRACK_STATE_CHANGE_LISTENER, - entity_ids, - job, - ) - - return remove_listener - @callback def _remove_empty_listener() -> None: """Remove a listener that does nothing.""" -@callback -def _async_remove_indexed_listeners( +def _async_track_event( hass: HomeAssistant, - data_key: str, - listener_key: str, - storage_keys: Iterable[str], - job: HassJob[[Event], Any], + keys: str | Iterable[str], + callbacks_key: str, + listeners_key: str, + event_type: str, + dispatcher_callable: Callable[ + [HomeAssistant, dict[str, list[HassJob[[Event], Any]]], Event], None + ], + filter_callable: Callable[ + [HomeAssistant, dict[str, list[HassJob[[Event], Any]]], Event], bool + ], + action: Callable[[Event], None], +) -> CALLBACK_TYPE: + """Track an event by a specific key.""" + if not keys: + return _remove_empty_listener + + if isinstance(keys, str): + keys = [keys] + + callbacks: dict[str, list[HassJob[[Event], Any]]] = hass.data.setdefault( + callbacks_key, {} + ) + + if listeners_key not in hass.data: + hass.data[listeners_key] = hass.bus.async_listen( + event_type, + callback(ft.partial(dispatcher_callable, hass, callbacks)), + event_filter=callback(ft.partial(filter_callable, hass, callbacks)), + ) + + job = HassJob(action, f"track {event_type} event {keys}") + + for key in keys: + callbacks.setdefault(key, []).append(job) + + @callback + def remove_listener() -> None: + """Remove listener.""" + for key in keys: + callbacks[key].remove(job) + if len(callbacks[key]) == 0: + del callbacks[key] + + if not callbacks: + hass.data[listeners_key]() + del hass.data[listeners_key] + + return remove_listener + + +@callback +def _async_dispatch_old_entity_id_or_entity_id_event( + hass: HomeAssistant, + callbacks: dict[str, list[HassJob[[Event], Any]]], + event: Event, ) -> None: - """Remove a listener.""" - callbacks: dict[str, list[HassJob[[Event], Any]]] = hass.data[data_key] + """Dispatch to listeners.""" + if not ( + callbacks_list := callbacks.get( + event.data.get("old_entity_id", event.data["entity_id"]) + ) + ): + return + for job in callbacks_list[:]: + try: + hass.async_run_hass_job(job, event) + except Exception: # pylint: disable=broad-except + _LOGGER.exception( + "Error while dispatching event for %s to %s", + event.data.get("old_entity_id", event.data["entity_id"]), + job, + ) - for storage_key in storage_keys: - callbacks[storage_key].remove(job) - if len(callbacks[storage_key]) == 0: - del callbacks[storage_key] - if not callbacks: - hass.data[listener_key]() - del hass.data[listener_key] +@callback +def _async_entity_registry_updated_filter( + hass: HomeAssistant, callbacks: dict[str, list[HassJob[[Event], Any]]], event: Event +) -> bool: + """Filter entity registry updates by entity_id.""" + return event.data.get("old_entity_id", event.data["entity_id"]) in callbacks @bind_hass @@ -353,76 +408,70 @@ def async_track_entity_registry_updated_event( Similar to async_track_state_change_event. """ - if not entity_ids: - return _remove_empty_listener - if isinstance(entity_ids, str): - entity_ids = [entity_ids] - - entity_callbacks: dict[str, list[HassJob[[Event], Any]]] = hass.data.setdefault( - TRACK_ENTITY_REGISTRY_UPDATED_CALLBACKS, {} + return _async_track_event( + hass, + entity_ids, + TRACK_ENTITY_REGISTRY_UPDATED_CALLBACKS, + TRACK_ENTITY_REGISTRY_UPDATED_LISTENER, + EVENT_ENTITY_REGISTRY_UPDATED, + _async_dispatch_old_entity_id_or_entity_id_event, + _async_entity_registry_updated_filter, + action, ) - if TRACK_ENTITY_REGISTRY_UPDATED_LISTENER not in hass.data: - - @callback - def _async_entity_registry_updated_filter(event: Event) -> bool: - """Filter entity registry updates by entity_id.""" - entity_id = event.data.get("old_entity_id", event.data["entity_id"]) - return entity_id in entity_callbacks - - @callback - def _async_entity_registry_updated_dispatcher(event: Event) -> None: - """Dispatch entity registry updates by entity_id.""" - entity_id = event.data.get("old_entity_id", event.data["entity_id"]) - - if entity_id not in entity_callbacks: - return - - for job in entity_callbacks[entity_id][:]: - try: - hass.async_run_hass_job(job, event) - except Exception: # pylint: disable=broad-except - _LOGGER.exception( - "Error while processing entity registry update for %s", - entity_id, - ) - - hass.data[TRACK_ENTITY_REGISTRY_UPDATED_LISTENER] = hass.bus.async_listen( - EVENT_ENTITY_REGISTRY_UPDATED, - _async_entity_registry_updated_dispatcher, - event_filter=_async_entity_registry_updated_filter, - ) - - job = HassJob(action, f"track entity registry updated event {entity_ids}") - - for entity_id in entity_ids: - entity_callbacks.setdefault(entity_id, []).append(job) - - @callback - def remove_listener() -> None: - """Remove state change listener.""" - _async_remove_indexed_listeners( - hass, - TRACK_ENTITY_REGISTRY_UPDATED_CALLBACKS, - TRACK_ENTITY_REGISTRY_UPDATED_LISTENER, - entity_ids, - job, - ) - - return remove_listener - @callback -def _async_domain_has_listeners( - domain: str, callbacks: dict[str, list[HassJob[[Event], Any]]] +def _async_device_registry_updated_filter( + hass: HomeAssistant, callbacks: dict[str, list[HassJob[[Event], Any]]], event: Event ) -> bool: - """Check if the domain has any listeners.""" - return domain in callbacks or MATCH_ALL in callbacks + """Filter device registry updates by device_id.""" + return event.data["device_id"] in callbacks + + +@callback +def _async_dispatch_device_id_event( + hass: HomeAssistant, + callbacks: dict[str, list[HassJob[[Event], Any]]], + event: Event, +) -> None: + """Dispatch to listeners.""" + if not (callbacks_list := callbacks.get(event.data["device_id"])): + return + for job in callbacks_list[:]: + try: + hass.async_run_hass_job(job, event) + except Exception: # pylint: disable=broad-except + _LOGGER.exception( + "Error while dispatching event for %s to %s", + event.data["device_id"], + job, + ) + + +def async_track_device_registry_updated_event( + hass: HomeAssistant, + device_ids: str | Iterable[str], + action: Callable[[Event], Any], +) -> CALLBACK_TYPE: + """Track specific device registry updated events indexed by device_id. + + Similar to async_track_entity_registry_updated_event. + """ + return _async_track_event( + hass, + device_ids, + TRACK_DEVICE_REGISTRY_UPDATED_CALLBACKS, + TRACK_DEVICE_REGISTRY_UPDATED_LISTENER, + EVENT_DEVICE_REGISTRY_UPDATED, + _async_dispatch_device_id_event, + _async_device_registry_updated_filter, + action, + ) @callback def _async_dispatch_domain_event( - hass: HomeAssistant, event: Event, callbacks: dict[str, list[HassJob[[Event], Any]]] + hass: HomeAssistant, callbacks: dict[str, list[HassJob[[Event], Any]]], event: Event ) -> None: """Dispatch domain event listeners.""" domain = split_entity_id(event.data["entity_id"])[0] @@ -435,6 +484,17 @@ def _async_dispatch_domain_event( ) +@callback +def _async_domain_added_filter( + hass: HomeAssistant, callbacks: dict[str, list[HassJob[[Event], Any]]], event: Event +) -> bool: + """Filter state changes by entity_id.""" + return event.data.get("old_state") is None and ( + MATCH_ALL in callbacks + or split_entity_id(event.data["entity_id"])[0] in callbacks + ) + + @bind_hass def async_track_state_added_domain( hass: HomeAssistant, @@ -453,48 +513,28 @@ def _async_track_state_added_domain( domains: str | Iterable[str], action: Callable[[Event], Any], ) -> CALLBACK_TYPE: - """async_track_state_added_domain without lowercasing.""" - domain_callbacks: dict[str, list[HassJob[[Event], Any]]] = hass.data.setdefault( - TRACK_STATE_ADDED_DOMAIN_CALLBACKS, {} + """Track state change events when an entity is added to domains.""" + return _async_track_event( + hass, + domains, + TRACK_STATE_ADDED_DOMAIN_CALLBACKS, + TRACK_STATE_ADDED_DOMAIN_LISTENER, + EVENT_STATE_CHANGED, + _async_dispatch_domain_event, + _async_domain_added_filter, + action, ) - if TRACK_STATE_ADDED_DOMAIN_LISTENER not in hass.data: - @callback - def _async_state_change_filter(event: Event) -> bool: - """Filter state changes by entity_id.""" - return event.data.get("old_state") is None and _async_domain_has_listeners( - split_entity_id(event.data["entity_id"])[0], domain_callbacks - ) - - @callback - def _async_state_change_dispatcher(event: Event) -> None: - """Dispatch state changes by entity_id.""" - _async_dispatch_domain_event(hass, event, domain_callbacks) - - hass.data[TRACK_STATE_ADDED_DOMAIN_LISTENER] = hass.bus.async_listen( - EVENT_STATE_CHANGED, - _async_state_change_dispatcher, - event_filter=_async_state_change_filter, - ) - - job = HassJob(action, f"track state added domain event {domains}") - - for domain in domains: - domain_callbacks.setdefault(domain, []).append(job) - - @callback - def remove_listener() -> None: - """Remove state change listener.""" - _async_remove_indexed_listeners( - hass, - TRACK_STATE_ADDED_DOMAIN_CALLBACKS, - TRACK_STATE_ADDED_DOMAIN_LISTENER, - domains, - job, - ) - - return remove_listener +@callback +def _async_domain_removed_filter( + hass: HomeAssistant, callbacks: dict[str, list[HassJob[[Event], Any]]], event: Event +) -> bool: + """Filter state changes by entity_id.""" + return event.data.get("new_state") is None and ( + MATCH_ALL in callbacks + or split_entity_id(event.data["entity_id"])[0] in callbacks + ) @bind_hass @@ -504,51 +544,17 @@ def async_track_state_removed_domain( action: Callable[[Event], Any], ) -> CALLBACK_TYPE: """Track state change events when an entity is removed from domains.""" - if not (domains := _async_string_to_lower_list(domains)): - return _remove_empty_listener - - domain_callbacks: dict[str, list[HassJob[[Event], Any]]] = hass.data.setdefault( - TRACK_STATE_REMOVED_DOMAIN_CALLBACKS, {} + return _async_track_event( + hass, + domains, + TRACK_STATE_REMOVED_DOMAIN_CALLBACKS, + TRACK_STATE_REMOVED_DOMAIN_LISTENER, + EVENT_STATE_CHANGED, + _async_dispatch_domain_event, + _async_domain_removed_filter, + action, ) - if TRACK_STATE_REMOVED_DOMAIN_LISTENER not in hass.data: - - @callback - def _async_state_change_filter(event: Event) -> bool: - """Filter state changes by entity_id.""" - return event.data.get("new_state") is None and _async_domain_has_listeners( - split_entity_id(event.data["entity_id"])[0], domain_callbacks - ) - - @callback - def _async_state_change_dispatcher(event: Event) -> None: - """Dispatch state changes by entity_id.""" - _async_dispatch_domain_event(hass, event, domain_callbacks) - - hass.data[TRACK_STATE_REMOVED_DOMAIN_LISTENER] = hass.bus.async_listen( - EVENT_STATE_CHANGED, - _async_state_change_dispatcher, - event_filter=_async_state_change_filter, - ) - - job = HassJob(action, f"track state removed domain event {domains}") - - for domain in domains: - domain_callbacks.setdefault(domain, []).append(job) - - @callback - def remove_listener() -> None: - """Remove state change listener.""" - _async_remove_indexed_listeners( - hass, - TRACK_STATE_REMOVED_DOMAIN_CALLBACKS, - TRACK_STATE_REMOVED_DOMAIN_LISTENER, - domains, - job, - ) - - return remove_listener - @callback def _async_string_to_lower_list(instr: str | Iterable[str]) -> list[str]: diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index 9d90ef1b26c3..3740a6b177a2 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -18,12 +18,14 @@ from homeassistant.const import MATCH_ALL import homeassistant.core as ha from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError +from homeassistant.helpers.device_registry import EVENT_DEVICE_REGISTRY_UPDATED from homeassistant.helpers.entity_registry import EVENT_ENTITY_REGISTRY_UPDATED from homeassistant.helpers.event import ( TrackStates, TrackTemplate, TrackTemplateResult, async_call_later, + async_track_device_registry_updated_event, async_track_entity_registry_updated_event, async_track_point_in_time, async_track_point_in_utc_time, @@ -4564,3 +4566,103 @@ async def test_async_track_entity_registry_updated_event_with_empty_list( unsub_single2() unsub_single() + + +async def test_async_track_device_registry_updated_event(hass: HomeAssistant) -> None: + """Test tracking device registry updates for an device_id.""" + + device_id = "b92c0f06fbc911edacc9eea8ae14f866" + device_id2 = "747bbf22fbca11ed843aeea8ae14f866" + untracked_device_id = "bda93f86fbc911edacc9eea8ae14f866" + + single_event_data = [] + multiple_event_data = [] + + @ha.callback + def single_device_id_callback(event: ha.Event) -> None: + single_event_data.append(event.data) + + @ha.callback + def multiple_device_id_callback(event: ha.Event) -> None: + multiple_event_data.append(event.data) + + unsub1 = async_track_device_registry_updated_event( + hass, device_id, single_device_id_callback + ) + unsub2 = async_track_device_registry_updated_event( + hass, [device_id, device_id2], multiple_device_id_callback + ) + hass.bus.async_fire( + EVENT_DEVICE_REGISTRY_UPDATED, {"action": "create", "device_id": device_id} + ) + hass.bus.async_fire( + EVENT_ENTITY_REGISTRY_UPDATED, + {"action": "create", "device_id": untracked_device_id}, + ) + await hass.async_block_till_done() + assert len(single_event_data) == 1 + assert len(multiple_event_data) == 1 + hass.bus.async_fire( + EVENT_DEVICE_REGISTRY_UPDATED, {"action": "create", "device_id": device_id2} + ) + await hass.async_block_till_done() + assert len(single_event_data) == 1 + assert len(multiple_event_data) == 2 + + unsub1() + unsub2() + hass.bus.async_fire( + EVENT_ENTITY_REGISTRY_UPDATED, {"action": "create", "device_id": device_id} + ) + hass.bus.async_fire( + EVENT_ENTITY_REGISTRY_UPDATED, {"action": "create", "device_id": device_id2} + ) + await hass.async_block_till_done() + assert len(single_event_data) == 1 + assert len(multiple_event_data) == 2 + + +async def test_async_track_device_registry_updated_event_with_empty_list( + hass: HomeAssistant, +) -> None: + """Test async_track_device_registry_updated_event passing an empty list of devices.""" + unsub_single = async_track_device_registry_updated_event( + hass, [], ha.callback(lambda event: None) + ) + unsub_single2 = async_track_device_registry_updated_event( + hass, [], ha.callback(lambda event: None) + ) + + unsub_single2() + unsub_single() + + +async def test_async_track_device_registry_updated_event_with_a_callback_that_throws( + hass: HomeAssistant, +) -> None: + """Test tracking device registry updates for an device when one callback throws.""" + + device_id = "b92c0f06fbc911edacc9eea8ae14f866" + + event_data = [] + + @ha.callback + def run_callback(event: ha.Event) -> None: + event_data.append(event.data) + + @ha.callback + def failing_callback(event: ha.Event) -> None: + raise ValueError + + unsub1 = async_track_device_registry_updated_event( + hass, device_id, failing_callback + ) + unsub2 = async_track_device_registry_updated_event(hass, device_id, run_callback) + hass.bus.async_fire( + EVENT_DEVICE_REGISTRY_UPDATED, {"action": "create", "device_id": device_id} + ) + await hass.async_block_till_done() + unsub1() + unsub2() + + assert event_data[0] == {"action": "create", "device_id": device_id} From cc12698f260683f70ab20b893817a9e23128a344 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 27 May 2023 19:04:39 -0500 Subject: [PATCH 615/822] Make diagnostics dataclasses sloted (#93600) Now available since our minimum python version is 3.10 Saves a tiny bit of ram --- homeassistant/components/diagnostics/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/diagnostics/__init__.py b/homeassistant/components/diagnostics/__init__.py index ea7b13f47191..2b06c4e5d619 100644 --- a/homeassistant/components/diagnostics/__init__.py +++ b/homeassistant/components/diagnostics/__init__.py @@ -33,7 +33,7 @@ __all__ = ["REDACTED", "async_redact_data"] _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(slots=True) class DiagnosticsPlatformData: """Diagnostic platform data.""" @@ -46,7 +46,7 @@ class DiagnosticsPlatformData: ] | None -@dataclass +@dataclass(slots=True) class DiagnosticsData: """Diagnostic data.""" From f3037d0b84486e9321b6a1edceaf8e7d8dab6f85 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 28 May 2023 02:29:18 +0200 Subject: [PATCH 616/822] Add reauth flow to Youtube (#93670) --- homeassistant/components/youtube/__init__.py | 4 +- homeassistant/components/youtube/api.py | 7 +- .../components/youtube/config_flow.py | 36 ++++++- tests/components/youtube/test_config_flow.py | 100 +++++++++++++++++- tests/components/youtube/test_init.py | 2 +- tests/components/youtube/test_sensor.py | 26 ++++- 6 files changed, 166 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/youtube/__init__.py b/homeassistant/components/youtube/__init__.py index 52e30d8093f4..b120359c0d78 100644 --- a/homeassistant/components/youtube/__init__.py +++ b/homeassistant/components/youtube/__init__.py @@ -6,7 +6,7 @@ from aiohttp.client_exceptions import ClientError, ClientResponseError from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import ( OAuth2Session, @@ -29,7 +29,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await auth.check_and_refresh_token() except ClientResponseError as err: if 400 <= err.status < 500: - raise ConfigEntryNotReady( + raise ConfigEntryAuthFailed( "OAuth session is not valid, reauth required" ) from err raise ConfigEntryNotReady from err diff --git a/homeassistant/components/youtube/api.py b/homeassistant/components/youtube/api.py index 70980987c338..b0d0bde2baa7 100644 --- a/homeassistant/components/youtube/api.py +++ b/homeassistant/components/youtube/api.py @@ -1,5 +1,6 @@ """API for YouTube bound to Home Assistant OAuth.""" from aiohttp import ClientSession +from google.auth.exceptions import RefreshError from google.oauth2.credentials import Credentials from google.oauth2.utils import OAuthClientAuthHandler from googleapiclient.discovery import Resource, build @@ -35,7 +36,11 @@ class AsyncConfigEntryAuth(OAuthClientAuthHandler): async def get_resource(self) -> Resource: """Create executor job to get current resource.""" - credentials = Credentials(await self.check_and_refresh_token()) + try: + credentials = Credentials(await self.check_and_refresh_token()) + except RefreshError as ex: + self.oauth_session.config_entry.async_start_reauth(self.oauth_session.hass) + raise ex return await self.hass.async_add_executor_job(self._get_resource, credentials) def _get_resource(self, credentials: Credentials) -> Resource: diff --git a/homeassistant/components/youtube/config_flow.py b/homeassistant/components/youtube/config_flow.py index 063238c07c93..3d7748e9b572 100644 --- a/homeassistant/components/youtube/config_flow.py +++ b/homeassistant/components/youtube/config_flow.py @@ -1,6 +1,7 @@ """Config flow for YouTube integration.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -10,6 +11,7 @@ from googleapiclient.errors import HttpError from googleapiclient.http import HttpRequest import voluptuous as vol +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_entry_oauth2_flow @@ -32,6 +34,8 @@ class OAuth2FlowHandler( DOMAIN = DOMAIN + reauth_entry: ConfigEntry | None = None + @property def logger(self) -> logging.Logger: """Return logger.""" @@ -47,6 +51,21 @@ class OAuth2FlowHandler( "prompt": "consent", } + 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_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult: """Create an entry for the flow, or update existing entry.""" try: @@ -71,10 +90,21 @@ class OAuth2FlowHandler( self._title = own_channel["snippet"]["title"] self._data = data - await self.async_set_unique_id(own_channel["id"]) - self._abort_if_unique_id_configured() + if not self.reauth_entry: + await self.async_set_unique_id(own_channel["id"]) + self._abort_if_unique_id_configured() - return await self.async_step_channels() + return await self.async_step_channels() + + if self.reauth_entry.unique_id == own_channel["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", + description_placeholders={"title": self._title}, + ) async def async_step_channels( self, user_input: dict[str, Any] | None = None diff --git a/tests/components/youtube/test_config_flow.py b/tests/components/youtube/test_config_flow.py index 006756959834..debe25c174f1 100644 --- a/tests/components/youtube/test_config_flow.py +++ b/tests/components/youtube/test_config_flow.py @@ -3,6 +3,7 @@ from unittest.mock import patch from googleapiclient.errors import HttpError from httplib2 import Response +import pytest from homeassistant import config_entries from homeassistant.components.youtube.const import CONF_CHANNELS, DOMAIN @@ -11,8 +12,10 @@ from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow from . import MockService -from .conftest import CLIENT_ID, GOOGLE_AUTH_URI, SCOPES, TITLE +from .conftest import CLIENT_ID, GOOGLE_AUTH_URI, GOOGLE_TOKEN_URI, SCOPES, TITLE +from tests.common import MockConfigEntry, load_fixture +from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator @@ -134,6 +137,101 @@ async def test_flow_http_error( ) +@pytest.mark.parametrize( + ("fixture", "abort_reason", "placeholders", "calls", "access_token"), + [ + ("get_channel", "reauth_successful", None, 1, "updated-access-token"), + ( + "get_channel_2", + "wrong_account", + {"title": "Linus Tech Tips"}, + 0, + "mock-access-token", + ), + ], +) +async def test_reauth( + hass: HomeAssistant, + hass_client_no_auth, + aioclient_mock: AiohttpClientMocker, + current_request_with_host, + config_entry: MockConfigEntry, + fixture: str, + abort_reason: str, + placeholders: dict[str, str], + calls: int, + access_token: str, +) -> None: + """Test the re-authentication case updates the correct config entry. + + Make sure we abort if the user selects the + wrong account on the consent screen. + """ + 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"] == ( + f"{GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope={'+'.join(SCOPES)}" + "&access_type=offline&prompt=consent" + ) + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.clear_requests() + aioclient_mock.post( + GOOGLE_TOKEN_URI, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "updated-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + with patch( + "homeassistant.components.youtube.async_setup_entry", return_value=True + ) as mock_setup, patch( + "httplib2.Http.request", + return_value=( + Response({}), + bytes(load_fixture(f"youtube/{fixture}.json"), encoding="UTF-8"), + ), + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + assert result["type"] == "abort" + assert result["reason"] == abort_reason + assert result["description_placeholders"] == placeholders + assert len(mock_setup.mock_calls) == calls + + assert config_entry.unique_id == "UC_x5XG1OV2P6uZZ5FSM9Ttw" + assert "token" in config_entry.data + # Verify access token is refreshed + assert config_entry.data["token"]["access_token"] == access_token + assert config_entry.data["token"]["refresh_token"] == "mock-refresh-token" + + async def test_flow_exception( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, diff --git a/tests/components/youtube/test_init.py b/tests/components/youtube/test_init.py index 9740d6083af8..02df1b0e32e2 100644 --- a/tests/components/youtube/test_init.py +++ b/tests/components/youtube/test_init.py @@ -67,7 +67,7 @@ async def test_expired_token_refresh_success( ( time.time() - 3600, http.HTTPStatus.UNAUTHORIZED, - ConfigEntryState.SETUP_RETRY, + ConfigEntryState.SETUP_ERROR, ), ( time.time() - 3600, diff --git a/tests/components/youtube/test_sensor.py b/tests/components/youtube/test_sensor.py index dfad86cf32e9..57788924d40d 100644 --- a/tests/components/youtube/test_sensor.py +++ b/tests/components/youtube/test_sensor.py @@ -1,9 +1,13 @@ """Sensor tests for the YouTube integration.""" +from unittest.mock import patch +from google.auth.exceptions import RefreshError +from homeassistant import config_entries +from homeassistant.components.youtube import COORDINATOR, DOMAIN from homeassistant.core import HomeAssistant -from .conftest import ComponentSetup +from .conftest import TOKEN, ComponentSetup async def test_sensor(hass: HomeAssistant, setup_integration: ComponentSetup) -> None: @@ -27,3 +31,23 @@ async def test_sensor(hass: HomeAssistant, setup_integration: ComponentSetup) -> state.attributes["entity_picture"] == "https://yt3.ggpht.com/fca_HuJ99xUxflWdex0XViC3NfctBFreIl8y4i9z411asnGTWY-Ql3MeH_ybA4kNaOjY7kyA=s800-c-k-c0x00ffffff-no-rj" ) + + +async def test_sensor_reauth_trigger( + hass: HomeAssistant, setup_integration: ComponentSetup +) -> None: + """Test reauth is triggered after a refresh error.""" + await setup_integration() + + with patch(TOKEN, side_effect=RefreshError): + entry = hass.config_entries.async_entries(DOMAIN)[0] + await hass.data[DOMAIN][entry.entry_id][COORDINATOR].async_refresh() + 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 From 5f5951e71c7c358e447611465be00dc058e85e25 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 27 May 2023 19:47:13 -0500 Subject: [PATCH 617/822] Add hardware model to onvif config flow discovery (#93676) --- homeassistant/components/onvif/config_flow.py | 20 +++++++++------- homeassistant/components/onvif/const.py | 1 + tests/components/onvif/test_config_flow.py | 23 ++++++++++++++++--- 3 files changed, 33 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/onvif/config_flow.py b/homeassistant/components/onvif/config_flow.py index 020649db87eb..0d0ddc180dfb 100644 --- a/homeassistant/components/onvif/config_flow.py +++ b/homeassistant/components/onvif/config_flow.py @@ -35,6 +35,7 @@ from homeassistant.helpers import device_registry as dr from .const import ( CONF_DEVICE_ID, CONF_ENABLE_WEBHOOKS, + CONF_HARDWARE, DEFAULT_ARGUMENTS, DEFAULT_ENABLE_WEBHOOKS, DEFAULT_PORT, @@ -71,11 +72,14 @@ async def async_discovery(hass: HomeAssistant) -> list[dict[str, Any]]: CONF_NAME: service.getEPR(), CONF_HOST: url.hostname, CONF_PORT: url.port or 80, + CONF_HARDWARE: None, } for scope in service.getScopes(): scope_str = scope.getValue() if scope_str.lower().startswith("onvif://www.onvif.org/name"): device[CONF_NAME] = scope_str.split("/")[-1] + if scope_str.lower().startswith("onvif://www.onvif.org/hardware"): + device[CONF_HARDWARE] = scope_str.split("/")[-1] if scope_str.lower().startswith("onvif://www.onvif.org/mac"): device[CONF_DEVICE_ID] = scope_str.split("/")[-1] devices.append(device) @@ -192,8 +196,7 @@ class OnvifFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_configure() for device in self.devices: - name = f"{device[CONF_NAME]} ({device[CONF_HOST]})" - if name == user_input[CONF_HOST]: + if device[CONF_HOST] == user_input[CONF_HOST]: self.device_id = device[CONF_DEVICE_ID] self.onvif_config = { CONF_NAME: device[CONF_NAME], @@ -215,15 +218,16 @@ class OnvifFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): LOGGER.debug("Discovered ONVIF devices %s", pformat(self.devices)) if self.devices: - names = [ - f"{device[CONF_NAME]} ({device[CONF_HOST]})" for device in self.devices - ] - - names.append(CONF_MANUAL_INPUT) + devices = {CONF_MANUAL_INPUT: CONF_MANUAL_INPUT} + for device in self.devices: + description = f"{device[CONF_NAME]} ({device[CONF_HOST]})" + if hardware := device[CONF_HARDWARE]: + description += f" [{hardware}]" + devices[device[CONF_HOST]] = description return self.async_show_form( step_id="device", - data_schema=vol.Schema({vol.Optional(CONF_HOST): vol.In(names)}), + data_schema=vol.Schema({vol.Optional(CONF_HOST): vol.In(devices)}), ) return await self.async_step_configure() diff --git a/homeassistant/components/onvif/const.py b/homeassistant/components/onvif/const.py index 8d95ef484bf9..77fa098a3160 100644 --- a/homeassistant/components/onvif/const.py +++ b/homeassistant/components/onvif/const.py @@ -13,6 +13,7 @@ DEFAULT_PORT = 80 DEFAULT_ARGUMENTS = "-pred 1" CONF_DEVICE_ID = "deviceid" +CONF_HARDWARE = "hardware" CONF_SNAPSHOT_AUTH = "snapshot_auth" CONF_ENABLE_WEBHOOKS = "enable_webhooks" DEFAULT_ENABLE_WEBHOOKS = True diff --git a/tests/components/onvif/test_config_flow.py b/tests/components/onvif/test_config_flow.py index 981620e7ae0c..adb8a33251c2 100644 --- a/tests/components/onvif/test_config_flow.py +++ b/tests/components/onvif/test_config_flow.py @@ -32,6 +32,7 @@ DISCOVERY = [ config_flow.CONF_HOST: HOST, config_flow.CONF_PORT: PORT, "MAC": MAC, + "HARDWARE": "IPC model", }, { "EPR": "urn:uuid:987654321", @@ -54,7 +55,11 @@ DHCP_DISCOVERY_SAME_IP = dhcp.DhcpServiceInfo( def setup_mock_discovery( - mock_discovery, with_name=False, with_mac=False, two_devices=False + mock_discovery, + with_name=False, + with_mac=False, + two_devices=False, + with_hardware=True, ): """Prepare mock discovery result.""" services = [] @@ -79,6 +84,12 @@ def setup_mock_discovery( return_value=f"onvif://www.onvif.org/mac/{item['MAC']}" ) scopes.append(scope) + if with_hardware and "HARDWARE" in item: + scope = MagicMock() + scope.getValue = MagicMock( + return_value=f"onvif://www.onvif.org/hardware/{item['HARDWARE']}" + ) + scopes.append(scope) service.getScopes = MagicMock(return_value=scopes) services.append(service) mock_discovery.return_value = services @@ -111,10 +122,16 @@ async def test_flow_discovered_devices(hass: HomeAssistant) -> None: assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["step_id"] == "device" - assert len(result["data_schema"].schema[config_flow.CONF_HOST].container) == 3 + container = result["data_schema"].schema[config_flow.CONF_HOST].container + assert len(container) == 3 + assert container == { + "Manually configure ONVIF device": "Manually configure ONVIF device", + "1.2.3.4": "urn:uuid:123456789 (1.2.3.4) [IPC model]", + "5.6.7.8": "urn:uuid:987654321 (5.6.7.8)", + } result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={config_flow.CONF_HOST: f"{URN} ({HOST})"} + result["flow_id"], user_input={config_flow.CONF_HOST: HOST} ) assert result["type"] == data_entry_flow.FlowResultType.FORM From fad3a4e168ad2e1f1d98fec3db0eaf321950e7bf Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 28 May 2023 02:58:04 +0200 Subject: [PATCH 618/822] Improve OpenSky typing (#93666) * Add types to OpenSky * Update .strict-typing * Add types to OpenSky --- .strict-typing | 1 + homeassistant/components/opensky/sensor.py | 16 +++++++++------- mypy.ini | 10 ++++++++++ 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/.strict-typing b/.strict-typing index 47f01c6cae86..801827df6dcc 100644 --- a/.strict-typing +++ b/.strict-typing @@ -236,6 +236,7 @@ homeassistant.components.oncue.* homeassistant.components.onewire.* homeassistant.components.open_meteo.* homeassistant.components.openexchangerates.* +homeassistant.components.opensky.* homeassistant.components.openuv.* homeassistant.components.otbr.* homeassistant.components.overkiz.* diff --git a/homeassistant/components/opensky/sensor.py b/homeassistant/components/opensky/sensor.py index f422eb150a08..f3704f8d5474 100644 --- a/homeassistant/components/opensky/sensor.py +++ b/homeassistant/components/opensky/sensor.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import timedelta -from python_opensky import BoundingBox, OpenSky +from python_opensky import BoundingBox, OpenSky, StateVector import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity @@ -120,16 +120,18 @@ class OpenSkySensor(SensorEntity): self._bounding_box = bounding_box @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) -> int: """Return the state of the sensor.""" return self._state - def _handle_boundary(self, flights, event, metadata): + def _handle_boundary( + self, flights: set[str], event: str, metadata: dict[str, StateVector] + ) -> None: """Handle flights crossing region boundary.""" for flight in flights: if flight in metadata: @@ -157,7 +159,7 @@ class OpenSkySensor(SensorEntity): async def async_update(self) -> None: """Update device state.""" currently_tracked = set() - flight_metadata = {} + flight_metadata: dict[str, StateVector] = {} response = await self._opensky.get_states(bounding_box=self._bounding_box) for flight in response.states: if not flight.callsign: @@ -187,11 +189,11 @@ class OpenSkySensor(SensorEntity): self._previously_tracked = currently_tracked @property - def native_unit_of_measurement(self): + def native_unit_of_measurement(self) -> str: """Return the unit of measurement.""" return "flights" @property - def icon(self): + def icon(self) -> str: """Return the icon.""" return "mdi:airplane" diff --git a/mypy.ini b/mypy.ini index 5e7d27586d1e..8628353ef6a7 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2122,6 +2122,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.opensky.*] +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.openuv.*] check_untyped_defs = true disallow_incomplete_defs = true From 02b76be0ba4f69e8bbee70870e477bb193a2e806 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sun, 28 May 2023 03:07:54 +0200 Subject: [PATCH 619/822] Use config entry setup in cast tests (#93595) * Use config entry setup in cast tests * Remove import step from config flow * Remove import tests * Fix tests --- homeassistant/components/cast/__init__.py | 34 +----------- homeassistant/components/cast/config_flow.py | 15 ----- homeassistant/components/cast/media_player.py | 11 ---- tests/components/cast/conftest.py | 3 +- tests/components/cast/test_config_flow.py | 1 - tests/components/cast/test_init.py | 50 ----------------- tests/components/cast/test_media_player.py | 55 ++----------------- 7 files changed, 8 insertions(+), 161 deletions(-) delete mode 100644 tests/components/cast/test_init.py diff --git a/homeassistant/components/cast/__init__.py b/homeassistant/components/cast/__init__.py index 4d1c00f967b4..aa0bdfa81187 100644 --- a/homeassistant/components/cast/__init__.py +++ b/homeassistant/components/cast/__init__.py @@ -1,14 +1,12 @@ """Component to embed Google Cast.""" from __future__ import annotations -import logging from typing import Protocol from pychromecast import Chromecast -import voluptuous as vol from homeassistant.components.media_player import BrowseMedia, MediaType -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -16,44 +14,14 @@ from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.integration_platform import ( async_process_integration_platforms, ) -from homeassistant.helpers.typing import ConfigType from . import home_assistant_cast from .const import DOMAIN -from .media_player import ENTITY_SCHEMA CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) - -_LOGGER = logging.getLogger(__name__) - PLATFORMS = [Platform.MEDIA_PLAYER] -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Cast component.""" - if (conf := config.get(DOMAIN)) is not None: - media_player_config_validated = [] - media_player_config = conf.get("media_player", {}) - if not isinstance(media_player_config, list): - media_player_config = [media_player_config] - for cfg in media_player_config: - try: - cfg = ENTITY_SCHEMA(cfg) - media_player_config_validated.append(cfg) - except vol.Error as ex: - _LOGGER.warning("Invalid config '%s': %s", cfg, ex) - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=media_player_config_validated, - ) - ) - - return True - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Cast from a config entry.""" await home_assistant_cast.async_setup_ha_cast(hass, entry) diff --git a/homeassistant/components/cast/config_flow.py b/homeassistant/components/cast/config_flow.py index a5fc43600970..e58bcb71b28e 100644 --- a/homeassistant/components/cast/config_flow.py +++ b/homeassistant/components/cast/config_flow.py @@ -38,21 +38,6 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Get the options flow for this handler.""" return CastOptionsFlowHandler(config_entry) - async def async_step_import(self, import_data=None): - """Import data.""" - if self._async_current_entries(): - return self.async_abort(reason="single_instance_allowed") - - media_player_config = import_data or [] - for cfg in media_player_config: - if CONF_IGNORE_CEC in cfg: - self._ignore_cec.update(set(cfg[CONF_IGNORE_CEC])) - if CONF_UUID in cfg: - self._wanted_uuid.add(cfg[CONF_UUID]) - - data = self._get_data() - return self.async_create_entry(title="Google Cast", data=data) - async def async_step_user(self, user_input=None): """Handle a flow initialized by the user.""" if self._async_current_entries(): diff --git a/homeassistant/components/cast/media_player.py b/homeassistant/components/cast/media_player.py index b701890d85db..3031eb8365b0 100644 --- a/homeassistant/components/cast/media_player.py +++ b/homeassistant/components/cast/media_player.py @@ -23,7 +23,6 @@ from pychromecast.socket_client import ( CONNECTION_STATUS_CONNECTED, CONNECTION_STATUS_DISCONNECTED, ) -import voluptuous as vol import yarl from homeassistant.components import media_source, zeroconf @@ -47,7 +46,6 @@ from homeassistant.const import ( ) from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -83,15 +81,6 @@ APP_IDS_UNRELIABLE_MEDIA_INFO = ("Netflix",) CAST_SPLASH = "https://www.home-assistant.io/images/cast/splash.png" -ENTITY_SCHEMA = vol.All( - vol.Schema( - { - vol.Optional(CONF_UUID): cv.string, - vol.Optional(CONF_IGNORE_CEC): vol.All(cv.ensure_list, [cv.string]), - } - ), -) - @callback def _async_create_cast_device(hass: HomeAssistant, info: ChromecastInfo): diff --git a/tests/components/cast/conftest.py b/tests/components/cast/conftest.py index dc50e42e458b..bd1dc00659e6 100644 --- a/tests/components/cast/conftest.py +++ b/tests/components/cast/conftest.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pychromecast +from pychromecast.controllers import multizone import pytest @@ -30,7 +31,7 @@ def castbrowser_mock(): @pytest.fixture def mz_mock(): """Mock pychromecast MultizoneManager.""" - return MagicMock(spec_set=pychromecast.controllers.multizone.MultizoneManager) + return MagicMock(spec_set=multizone.MultizoneManager) @pytest.fixture diff --git a/tests/components/cast/test_config_flow.py b/tests/components/cast/test_config_flow.py index c1ecedcf5515..2d688489d39c 100644 --- a/tests/components/cast/test_config_flow.py +++ b/tests/components/cast/test_config_flow.py @@ -39,7 +39,6 @@ async def test_creating_entry_sets_up_media_player(hass: HomeAssistant) -> None: @pytest.mark.parametrize( "source", [ - config_entries.SOURCE_IMPORT, config_entries.SOURCE_USER, config_entries.SOURCE_ZEROCONF, ], diff --git a/tests/components/cast/test_init.py b/tests/components/cast/test_init.py deleted file mode 100644 index 69640e29cef1..000000000000 --- a/tests/components/cast/test_init.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Tests for the Cast integration.""" -from unittest.mock import patch - -import pytest - -from homeassistant.components import cast -from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component - - -async def test_import(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> None: - """Test that specifying config will create an entry.""" - with patch( - "homeassistant.components.cast.async_setup_entry", return_value=True - ) as mock_setup: - await async_setup_component( - hass, - cast.DOMAIN, - { - "cast": { - "media_player": [ - {"uuid": "abcd"}, - {"uuid": "abcd", "ignore_cec": "milk"}, - {"uuid": "efgh", "ignore_cec": "beer"}, - {"incorrect": "config"}, - ] - } - }, - ) - await hass.async_block_till_done() - - assert len(mock_setup.mock_calls) == 1 - - assert len(hass.config_entries.async_entries("cast")) == 1 - entry = hass.config_entries.async_entries("cast")[0] - assert set(entry.data["ignore_cec"]) == {"milk", "beer"} - assert set(entry.data["uuid"]) == {"abcd", "efgh"} - - assert "Invalid config '{'incorrect': 'config'}'" in caplog.text - - -async def test_not_configuring_cast_not_creates_entry(hass: HomeAssistant) -> None: - """Test that an empty config does not create an entry.""" - with patch( - "homeassistant.components.cast.async_setup_entry", return_value=True - ) as mock_setup: - await async_setup_component(hass, cast.DOMAIN, {}) - await hass.async_block_till_done() - - assert len(mock_setup.mock_calls) == 0 diff --git a/tests/components/cast/test_media_player.py b/tests/components/cast/test_media_player.py index 46a778f5e314..f290754d6fab 100644 --- a/tests/components/cast/test_media_player.py +++ b/tests/components/cast/test_media_player.py @@ -181,7 +181,7 @@ async def async_setup_cast_internal_discovery(hass, config=None): async def async_setup_media_player_cast(hass: HomeAssistant, info: ChromecastInfo): - """Set up the cast platform with async_setup_component.""" + """Set up a cast config entry.""" browser = MagicMock(devices={}, zc={}) chromecast = get_fake_chromecast(info) zconf = get_fake_zconf(host=info.cast_info.host, port=info.cast_info.port) @@ -196,9 +196,10 @@ async def async_setup_media_player_cast(hass: HomeAssistant, info: ChromecastInf "homeassistant.components.cast.discovery.ChromeCastZeroconf.get_zeroconf", return_value=zconf, ): - await async_setup_component( - hass, "cast", {"cast": {"media_player": {"uuid": info.uuid}}} - ) + data = {"ignore_cec": [], "known_hosts": [], "uuid": [str(info.uuid)]} + entry = MockConfigEntry(data=data, domain="cast") + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() await hass.async_block_till_done() @@ -2014,52 +2015,6 @@ async def test_entry_setup_no_config(hass: HomeAssistant) -> None: assert not hass.config_entries.async_entries("cast") -async def test_entry_setup_empty_config(hass: HomeAssistant) -> None: - """Test deprecated empty yaml config..""" - await async_setup_component(hass, "cast", {"cast": {}}) - await hass.async_block_till_done() - - config_entry = hass.config_entries.async_entries("cast")[0] - assert config_entry.data["uuid"] == [] - assert config_entry.data["ignore_cec"] == [] - - -async def test_entry_setup_single_config(hass: HomeAssistant) -> None: - """Test deprecated yaml config with a single config media_player.""" - await async_setup_component( - hass, "cast", {"cast": {"media_player": {"uuid": "bla", "ignore_cec": "cast1"}}} - ) - await hass.async_block_till_done() - - config_entry = hass.config_entries.async_entries("cast")[0] - assert config_entry.data["uuid"] == ["bla"] - assert config_entry.data["ignore_cec"] == ["cast1"] - - assert ["cast1"] == pychromecast.IGNORE_CEC - - -async def test_entry_setup_list_config(hass: HomeAssistant) -> None: - """Test deprecated yaml config with multiple media_players.""" - await async_setup_component( - hass, - "cast", - { - "cast": { - "media_player": [ - {"uuid": "bla", "ignore_cec": "cast1"}, - {"uuid": "blu", "ignore_cec": ["cast2", "cast3"]}, - ] - } - }, - ) - await hass.async_block_till_done() - - config_entry = hass.config_entries.async_entries("cast")[0] - assert set(config_entry.data["uuid"]) == {"bla", "blu"} - assert set(config_entry.data["ignore_cec"]) == {"cast1", "cast2", "cast3"} - assert set(pychromecast.IGNORE_CEC) == {"cast1", "cast2", "cast3"} - - async def test_invalid_cast_platform( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: From 41882d833df9002b622a57735556e9bed6feb52e Mon Sep 17 00:00:00 2001 From: Sven Serlier <85389871+wrt54g@users.noreply.github.com> Date: Sun, 28 May 2023 08:52:52 +0200 Subject: [PATCH 620/822] Update dialogflow URL (#93616) Update URL --- homeassistant/components/dialogflow/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/dialogflow/config_flow.py b/homeassistant/components/dialogflow/config_flow.py index fee99898ccc1..7e62869c3fa7 100644 --- a/homeassistant/components/dialogflow/config_flow.py +++ b/homeassistant/components/dialogflow/config_flow.py @@ -7,7 +7,7 @@ config_entry_flow.register_webhook_flow( DOMAIN, "Dialogflow Webhook", { - "dialogflow_url": "https://dialogflow.com/docs/fulfillment#webhook", + "dialogflow_url": "https://cloud.google.com/dialogflow/es/docs/fulfillment-webhook", "docs_url": "https://www.home-assistant.io/integrations/dialogflow/", }, ) From 202c9071a4d1f7cbbb07e66454198362cc1a9569 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 28 May 2023 10:52:30 +0200 Subject: [PATCH 621/822] Improve Youtube integration (#93683) * Fix feedback * Fix feedback --- homeassistant/components/youtube/coordinator.py | 3 ++- homeassistant/components/youtube/entity.py | 6 ++++-- homeassistant/components/youtube/strings.json | 10 ---------- tests/components/youtube/test_sensor.py | 9 ++++++--- 4 files changed, 12 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/youtube/coordinator.py b/homeassistant/components/youtube/coordinator.py index 4cbe19d48450..fd489e37e98f 100644 --- a/homeassistant/components/youtube/coordinator.py +++ b/homeassistant/components/youtube/coordinator.py @@ -31,7 +31,8 @@ from .const import ( def get_upload_playlist_id(channel_id: str) -> str: """Return the playlist id with the uploads of the channel. - Replacing the UC in the channel id (UCxxxxxxxxxxxx) with UU is the way to do it without extra request (UUxxxxxxxxxxxx). + Replacing the UC in the channel id (UCxxxxxxxxxxxx) with UU is + the way to do it without extra request (UUxxxxxxxxxxxx). """ return channel_id.replace("UC", "UU", 1) diff --git a/homeassistant/components/youtube/entity.py b/homeassistant/components/youtube/entity.py index 0f1d0c0aeb92..cdc2f98faac7 100644 --- a/homeassistant/components/youtube/entity.py +++ b/homeassistant/components/youtube/entity.py @@ -5,13 +5,14 @@ from typing import Any from homeassistant.const import ATTR_ID from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription +from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ATTR_TITLE, DOMAIN, MANUFACTURER from .coordinator import YouTubeDataUpdateCoordinator -class YouTubeChannelEntity(Entity): +class YouTubeChannelEntity(CoordinatorEntity): """An HA implementation for YouTube entity.""" _attr_has_entity_name = True @@ -23,6 +24,7 @@ class YouTubeChannelEntity(Entity): channel: dict[str, Any], ) -> None: """Initialize a Google Mail entity.""" + super().__init__(coordinator) self.entity_description = description self._attr_unique_id = ( f"{coordinator.config_entry.entry_id}_{channel[ATTR_ID]}_{description.key}" diff --git a/homeassistant/components/youtube/strings.json b/homeassistant/components/youtube/strings.json index 24369ab26f97..f9a2d3af0ae3 100644 --- a/homeassistant/components/youtube/strings.json +++ b/homeassistant/components/youtube/strings.json @@ -19,16 +19,6 @@ } } }, - "options": { - "step": { - "init": { - "description": "Select the channels you want to add.", - "data": { - "channels": "YouTube channels" - } - } - } - }, "entity": { "sensor": { "latest_upload": { diff --git a/tests/components/youtube/test_sensor.py b/tests/components/youtube/test_sensor.py index 57788924d40d..b70b9dc2bd9f 100644 --- a/tests/components/youtube/test_sensor.py +++ b/tests/components/youtube/test_sensor.py @@ -1,12 +1,15 @@ """Sensor tests for the YouTube integration.""" +from datetime import timedelta from unittest.mock import patch from google.auth.exceptions import RefreshError from homeassistant import config_entries -from homeassistant.components.youtube import COORDINATOR, DOMAIN +from homeassistant.components.youtube import DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.util import dt +from ...common import async_fire_time_changed from .conftest import TOKEN, ComponentSetup @@ -40,8 +43,8 @@ async def test_sensor_reauth_trigger( await setup_integration() with patch(TOKEN, side_effect=RefreshError): - entry = hass.config_entries.async_entries(DOMAIN)[0] - await hass.data[DOMAIN][entry.entry_id][COORDINATOR].async_refresh() + future = dt.utcnow() + timedelta(minutes=15) + async_fire_time_changed(hass, future) await hass.async_block_till_done() flows = hass.config_entries.flow.async_progress() From 4c0d169cfc6e767fcafbb405cb79be1d186576ff Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 28 May 2023 13:28:11 +0200 Subject: [PATCH 622/822] Fix imap parsing non rfc compliant date crash (#93630) * Fix imap parsing non rfc compliant date crash * Use parsedate_to_datetime from mail.utils --- homeassistant/components/imap/coordinator.py | 29 +++++++------ tests/components/imap/const.py | 43 +++++++++++++++++--- tests/components/imap/test_init.py | 12 ++++-- 3 files changed, 64 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index b478d475f9a6..fc6b0c28114a 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -5,6 +5,8 @@ import asyncio from collections.abc import Mapping from datetime import datetime, timedelta import email +from email.header import decode_header, make_header +from email.utils import parseaddr, parsedate_to_datetime import logging from typing import Any @@ -82,9 +84,9 @@ class ImapMessage: """Get the email headers.""" header_base: dict[str, tuple[str,]] = {} for key, value in self.email_message.items(): - header: tuple[str,] = (str(value),) - if header_base.setdefault(key, header) != header: - header_base[key] += header # type: ignore[assignment] + header_instances: tuple[str,] = (str(value),) + if header_base.setdefault(key, header_instances) != header_instances: + header_base[key] += header_instances # type: ignore[assignment] return header_base @property @@ -94,23 +96,26 @@ class ImapMessage: date_str: str | None if (date_str := self.email_message["Date"]) is None: return None - # In some cases a timezone or comment is added in parenthesis after the date - # We want to strip that part to avoid parsing errors - return datetime.strptime( - date_str.split("(")[0].strip(), "%a, %d %b %Y %H:%M:%S %z" - ) + try: + mail_dt_tm = parsedate_to_datetime(date_str) + except ValueError: + _LOGGER.debug( + "Parsed date %s is not compliant with rfc2822#section-3.3", date_str + ) + return None + return mail_dt_tm @property def sender(self) -> str: """Get the parsed message sender from the email.""" - return str(email.utils.parseaddr(self.email_message["From"])[1]) + return str(parseaddr(self.email_message["From"])[1]) @property def subject(self) -> str: """Decode the message subject.""" - decoded_header = email.header.decode_header(self.email_message["Subject"]) - header = email.header.make_header(decoded_header) - return str(header) + decoded_header = decode_header(self.email_message["Subject"]) + subject_header = make_header(decoded_header) + return str(subject_header) @property def text(self) -> str: diff --git a/tests/components/imap/const.py b/tests/components/imap/const.py index 7c774527b31b..15b56547894f 100644 --- a/tests/components/imap/const.py +++ b/tests/components/imap/const.py @@ -3,7 +3,10 @@ DATE_HEADER1 = b"Date: Fri, 24 Mar 2023 13:52:00 +0100\r\n" DATE_HEADER2 = b"Date: Fri, 24 Mar 2023 13:52:00 +0100 (CET)\r\n" -DATE_HEADER_INVALID = b"2023-03-27T13:52:00 +0100\r\n" +DATE_HEADER3 = b"Date: 24 Mar 2023 13:52:00 +0100\r\n" +DATE_HEADER_INVALID1 = b"2023-03-27T13:52:00 +0100\r\n" +DATE_HEADER_INVALID2 = b"Date: 2023-03-27T13:52:00 +0100\r\n" +DATE_HEADER_INVALID3 = b"Date: Fri, 2023-03-27T13:52:00 +0100\r\n" TEST_MESSAGE_HEADERS1 = ( b"Return-Path: \r\nDelivered-To: notify@example.com\r\n" @@ -23,7 +26,15 @@ TEST_MESSAGE_HEADERS2 = ( TEST_MESSAGE = TEST_MESSAGE_HEADERS1 + DATE_HEADER1 + TEST_MESSAGE_HEADERS2 TEST_MESSAGE_ALT = TEST_MESSAGE_HEADERS1 + DATE_HEADER2 + TEST_MESSAGE_HEADERS2 -TEST_INVALID_DATE = TEST_MESSAGE_HEADERS1 + DATE_HEADER_INVALID + TEST_MESSAGE_HEADERS2 +TEST_INVALID_DATE1 = ( + TEST_MESSAGE_HEADERS1 + DATE_HEADER_INVALID1 + TEST_MESSAGE_HEADERS2 +) +TEST_INVALID_DATE2 = ( + TEST_MESSAGE_HEADERS1 + DATE_HEADER_INVALID2 + TEST_MESSAGE_HEADERS2 +) +TEST_INVALID_DATE3 = ( + TEST_MESSAGE_HEADERS1 + DATE_HEADER_INVALID3 + TEST_MESSAGE_HEADERS2 +) TEST_CONTENT_TEXT_BARE = b"\r\n" b"Test body\r\n" b"\r\n" @@ -110,13 +121,35 @@ TEST_FETCH_RESPONSE_TEXT_PLAIN_ALT = ( ], ) -TEST_FETCH_RESPONSE_INVALID_DATE = ( +TEST_FETCH_RESPONSE_INVALID_DATE1 = ( "OK", [ b"1 FETCH (BODY[] {" - + str(len(TEST_INVALID_DATE + TEST_CONTENT_TEXT_PLAIN)).encode("utf-8") + + str(len(TEST_INVALID_DATE1 + TEST_CONTENT_TEXT_PLAIN)).encode("utf-8") + b"}", - bytearray(TEST_INVALID_DATE + TEST_CONTENT_TEXT_PLAIN), + bytearray(TEST_INVALID_DATE1 + TEST_CONTENT_TEXT_PLAIN), + b")", + b"Fetch completed (0.0001 + 0.000 secs).", + ], +) +TEST_FETCH_RESPONSE_INVALID_DATE2 = ( + "OK", + [ + b"1 FETCH (BODY[] {" + + str(len(TEST_INVALID_DATE2 + TEST_CONTENT_TEXT_PLAIN)).encode("utf-8") + + b"}", + bytearray(TEST_INVALID_DATE2 + TEST_CONTENT_TEXT_PLAIN), + b")", + b"Fetch completed (0.0001 + 0.000 secs).", + ], +) +TEST_FETCH_RESPONSE_INVALID_DATE3 = ( + "OK", + [ + b"1 FETCH (BODY[] {" + + str(len(TEST_INVALID_DATE3 + TEST_CONTENT_TEXT_PLAIN)).encode("utf-8") + + b"}", + bytearray(TEST_INVALID_DATE3 + TEST_CONTENT_TEXT_PLAIN), b")", b"Fetch completed (0.0001 + 0.000 secs).", ], diff --git a/tests/components/imap/test_init.py b/tests/components/imap/test_init.py index 801ce0504324..2fc5a56a0a05 100644 --- a/tests/components/imap/test_init.py +++ b/tests/components/imap/test_init.py @@ -18,7 +18,9 @@ from .const import ( EMPTY_SEARCH_RESPONSE, TEST_FETCH_RESPONSE_BINARY, TEST_FETCH_RESPONSE_HTML, - TEST_FETCH_RESPONSE_INVALID_DATE, + TEST_FETCH_RESPONSE_INVALID_DATE1, + TEST_FETCH_RESPONSE_INVALID_DATE2, + TEST_FETCH_RESPONSE_INVALID_DATE3, TEST_FETCH_RESPONSE_MULTIPART, TEST_FETCH_RESPONSE_TEXT_BARE, TEST_FETCH_RESPONSE_TEXT_OTHER, @@ -81,7 +83,9 @@ async def test_entry_startup_fails( (TEST_FETCH_RESPONSE_TEXT_BARE, True), (TEST_FETCH_RESPONSE_TEXT_PLAIN, True), (TEST_FETCH_RESPONSE_TEXT_PLAIN_ALT, True), - (TEST_FETCH_RESPONSE_INVALID_DATE, False), + (TEST_FETCH_RESPONSE_INVALID_DATE1, False), + (TEST_FETCH_RESPONSE_INVALID_DATE2, False), + (TEST_FETCH_RESPONSE_INVALID_DATE3, False), (TEST_FETCH_RESPONSE_TEXT_OTHER, True), (TEST_FETCH_RESPONSE_HTML, True), (TEST_FETCH_RESPONSE_MULTIPART, True), @@ -91,7 +95,9 @@ async def test_entry_startup_fails( "bare", "plain", "plain_alt", - "invalid_date", + "invalid_date1", + "invalid_date2", + "invalid_date3", "other", "html", "multipart", From 7ff1c7951425d5b8ab875c9274abc6e366a518b4 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Sun, 28 May 2023 15:22:38 +0200 Subject: [PATCH 623/822] Update xknx to 2.10.0 and knx-frontend to 2023.5.28.94855 (#93694) --- homeassistant/components/knx/manifest.json | 4 ++-- requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index b850954e8a8d..63306ca5d19a 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -11,8 +11,8 @@ "loggers": ["xknx", "xknxproject"], "quality_scale": "platinum", "requirements": [ - "xknx==2.9.0", + "xknx==2.10.0", "xknxproject==3.1.0", - "knx_frontend==2023.5.16.204359" + "knx_frontend==2023.5.28.94855" ] } diff --git a/requirements_all.txt b/requirements_all.txt index 54de1740d7ef..f97ba7653c9b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1032,7 +1032,7 @@ kegtron-ble==0.4.0 kiwiki-client==0.1.1 # homeassistant.components.knx -knx_frontend==2023.5.16.204359 +knx_frontend==2023.5.28.94855 # homeassistant.components.konnected konnected==1.2.0 @@ -2680,7 +2680,7 @@ xbox-webapi==2.0.11 xiaomi-ble==0.17.0 # homeassistant.components.knx -xknx==2.9.0 +xknx==2.10.0 # homeassistant.components.knx xknxproject==3.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 61e30b3fb04c..2ee4e7ca095a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -791,7 +791,7 @@ justnimbus==0.6.0 kegtron-ble==0.4.0 # homeassistant.components.knx -knx_frontend==2023.5.16.204359 +knx_frontend==2023.5.28.94855 # homeassistant.components.konnected konnected==1.2.0 @@ -1950,7 +1950,7 @@ xbox-webapi==2.0.11 xiaomi-ble==0.17.0 # homeassistant.components.knx -xknx==2.9.0 +xknx==2.10.0 # homeassistant.components.knx xknxproject==3.1.0 From 49c3a8886f1a92e86426b1947ff75559231f6d2a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sun, 28 May 2023 15:54:22 +0200 Subject: [PATCH 624/822] Make cv.empty_config_schema log an error instead of raise (#93646) * Make cv.empty_config_schema log an error instead of raise * Add test * Update homeassistant/helpers/config_validation.py Co-authored-by: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> --------- Co-authored-by: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> --- homeassistant/helpers/config_validation.py | 30 +++++++++++++++++++--- tests/helpers/test_config_validation.py | 22 ++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index bbe4e05fd344..78c63049e5f9 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -1045,9 +1045,33 @@ def expand_condition_shorthand(value: Any | None) -> Any: # Schemas -def empty_config_schema(domain: str) -> vol.Schema: - """Return a config schema which accepts no configuration parameters.""" - return vol.Schema({vol.Optional(domain): vol.Schema({})}, extra=vol.ALLOW_EXTRA) +def empty_config_schema(domain: str) -> Callable[[dict], dict]: + """Return a config schema which logs if there are configuration parameters.""" + + module = inspect.getmodule(inspect.stack(context=0)[2].frame) + if module is not None: + module_name = module.__name__ + else: + # If Python is unable to access the sources files, the call stack frame + # will be missing information, so let's guard. + # https://github.com/home-assistant/core/issues/24982 + module_name = __name__ + logger_func = logging.getLogger(module_name).error + + def validator(config: dict) -> dict: + if domain in config and config[domain]: + logger_func( + ( + "The %s integration does not support any configuration parameters, " + "got %s. Please remove the configuration parameters from your " + "configuration." + ), + domain, + config[domain], + ) + return config + + return validator PLATFORM_SCHEMA = vol.Schema( diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index f1f644a36a70..df8e989d672c 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -1468,3 +1468,25 @@ def test_positive_time_period_template() -> None: schema("{{ 'invalid' }}") schema({"{{ 'invalid' }}": 5}) schema({"minutes": "{{ 'invalid' }}"}) + + +def test_empty_schema(caplog: pytest.LogCaptureFixture) -> None: + """Test if the current module cannot be inspected.""" + expected_message = ( + "The test_domain integration does not support any configuration parameters" + ) + + cv.empty_config_schema("test_domain")({}) + assert expected_message not in caplog.text + + cv.empty_config_schema("test_domain")({"test_domain": {}}) + assert expected_message not in caplog.text + + cv.empty_config_schema("test_domain")({"test_domain": {"foo": "bar"}}) + assert expected_message in caplog.text + + +def test_empty_schema_cant_find_module() -> None: + """Test if the current module cannot be inspected.""" + with patch("inspect.getmodule", return_value=None): + cv.empty_config_schema("test_domain")({"test_domain": {"foo": "bar"}}) From 083cf7a38b89b630e69d8b82eff0ee6defda5c88 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 28 May 2023 09:20:48 -0500 Subject: [PATCH 625/822] Fix refactoring error with updating polling entities in sequence (#93693) * Fix refactoring error with updating in sequence see #93649 * coverage * make sure entities are being updated in parallel * make sure entities are being updated in sequence --- homeassistant/helpers/entity_platform.py | 14 ++-- tests/helpers/test_entity_platform.py | 101 +++++++++++++++++++++++ 2 files changed, 109 insertions(+), 6 deletions(-) diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index be335c7a40f7..52d436ca997e 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -136,7 +136,7 @@ class EntityPlatform: self._process_updates: asyncio.Lock | None = None self.parallel_updates: asyncio.Semaphore | None = None - self._update_in_parallel: bool = True + self._update_in_sequence: bool = False # Platform is None for the EntityComponent "catch-all" EntityPlatform # which powers entity_component.add_entities @@ -187,7 +187,7 @@ class EntityPlatform: if parallel_updates is not None: self.parallel_updates = asyncio.Semaphore(parallel_updates) - self._update_in_parallel = parallel_updates != 1 + self._update_in_sequence = parallel_updates == 1 return self.parallel_updates @@ -846,11 +846,13 @@ class EntityPlatform: return async with self._process_updates: - if self._update_in_parallel or len(self.entities) <= 1: - # If we know are going to update sequentially, we want to update - # to avoid scheduling the coroutines as tasks that will we know - # are going to wait on the semaphore lock. + if self._update_in_sequence or len(self.entities) <= 1: + # If we know we will update sequentially, we want to avoid scheduling + # the coroutines as tasks that will wait on the semaphore lock. for entity in list(self.entities.values()): + # If the entity is removed from hass during the previous + # entity being updated, we need to skip updating the + # entity. if entity.should_poll and entity.hass: await entity.async_update_ha_state(True) return diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index e6b864be09cb..346a124424ef 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -2,6 +2,7 @@ import asyncio from datetime import timedelta import logging +from typing import Any from unittest.mock import ANY, Mock, patch import pytest @@ -307,6 +308,7 @@ async def test_parallel_updates_async_platform(hass: HomeAssistant) -> None: entity = AsyncEntity() await handle.async_add_entities([entity]) assert entity.parallel_updates is None + assert handle._update_in_sequence is False async def test_parallel_updates_async_platform_with_constant( @@ -336,6 +338,7 @@ async def test_parallel_updates_async_platform_with_constant( await handle.async_add_entities([entity]) assert entity.parallel_updates is not None assert entity.parallel_updates._value == 2 + assert handle._update_in_sequence is False async def test_parallel_updates_sync_platform(hass: HomeAssistant) -> None: @@ -412,6 +415,104 @@ async def test_parallel_updates_sync_platform_with_constant( assert entity.parallel_updates._value == 2 +async def test_parallel_updates_async_platform_updates_in_parallel( + hass: HomeAssistant, +) -> None: + """Test an async platform is updated in parallel.""" + platform = MockPlatform() + + mock_entity_platform(hass, "test_domain.async_platform", platform) + + component = EntityComponent(_LOGGER, DOMAIN, hass) + component._platforms = {} + + await component.async_setup({DOMAIN: {"platform": "async_platform"}}) + await hass.async_block_till_done() + + handle = list(component._platforms.values())[-1] + updating = [] + peak_update_count = 0 + + class AsyncEntity(MockEntity): + """Mock entity that has async_update.""" + + async def async_update(self): + pass + + async def async_update_ha_state(self, *args: Any, **kwargs: Any) -> None: + nonlocal peak_update_count + updating.append(self.entity_id) + await asyncio.sleep(0) + peak_update_count = max(len(updating), peak_update_count) + await asyncio.sleep(0) + updating.remove(self.entity_id) + + entity1 = AsyncEntity() + entity2 = AsyncEntity() + entity3 = AsyncEntity() + + await handle.async_add_entities([entity1, entity2, entity3]) + + assert entity1.parallel_updates is None + assert entity2.parallel_updates is None + assert entity3.parallel_updates is None + + assert handle._update_in_sequence is False + + await handle._update_entity_states(dt_util.utcnow()) + assert peak_update_count > 1 + + +async def test_parallel_updates_sync_platform_updates_in_sequence( + hass: HomeAssistant, +) -> None: + """Test a sync platform is updated in sequence.""" + platform = MockPlatform() + + mock_entity_platform(hass, "test_domain.platform", platform) + + component = EntityComponent(_LOGGER, DOMAIN, hass) + component._platforms = {} + + await component.async_setup({DOMAIN: {"platform": "platform"}}) + await hass.async_block_till_done() + + handle = list(component._platforms.values())[-1] + updating = [] + peak_update_count = 0 + + class SyncEntity(MockEntity): + """Mock entity that has update.""" + + def update(self): + pass + + async def async_update_ha_state(self, *args: Any, **kwargs: Any) -> None: + nonlocal peak_update_count + updating.append(self.entity_id) + await asyncio.sleep(0) + peak_update_count = max(len(updating), peak_update_count) + await asyncio.sleep(0) + updating.remove(self.entity_id) + + entity1 = SyncEntity() + entity2 = SyncEntity() + entity3 = SyncEntity() + + await handle.async_add_entities([entity1, entity2, entity3]) + assert entity1.parallel_updates is not None + assert entity1.parallel_updates._value == 1 + assert entity2.parallel_updates is not None + assert entity2.parallel_updates._value == 1 + assert entity3.parallel_updates is not None + assert entity3.parallel_updates._value == 1 + + assert handle._update_in_sequence is True + + await handle._update_entity_states(dt_util.utcnow()) + assert peak_update_count == 1 + + async def test_raise_error_on_update(hass: HomeAssistant) -> None: """Test the add entity if they raise an error on update.""" updates = [] From 25b5ad7de418ea9eefd50d687ab12acaaa7d8625 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sun, 28 May 2023 17:11:46 +0200 Subject: [PATCH 626/822] Fix typo in ping (#93701) --- homeassistant/components/ping/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/ping/__init__.py b/homeassistant/components/ping/__init__.py index 2236b8dc3374..b78702de54d2 100644 --- a/homeassistant/components/ping/__init__.py +++ b/homeassistant/components/ping/__init__.py @@ -15,7 +15,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the template integration.""" + """Set up the ping integration.""" await async_setup_reload_service(hass, DOMAIN, PLATFORMS) hass.data[DOMAIN] = { PING_PRIVS: await hass.async_add_executor_job(_can_use_icmp_lib_with_privilege), From 68c646be400b4d3ed5157d59e95541eba3621278 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 28 May 2023 17:48:43 +0200 Subject: [PATCH 627/822] Improve async youtube (#93685) * Improve async youtube * Improve async youtube --- .../components/youtube/coordinator.py | 48 ++++++++++--------- 1 file changed, 25 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/youtube/coordinator.py b/homeassistant/components/youtube/coordinator.py index fd489e37e98f..190e79e33931 100644 --- a/homeassistant/components/youtube/coordinator.py +++ b/homeassistant/components/youtube/coordinator.py @@ -1,7 +1,6 @@ """DataUpdateCoordinator for the YouTube integration.""" from __future__ import annotations -import asyncio from datetime import timedelta from typing import Any @@ -53,7 +52,6 @@ class YouTubeDataUpdateCoordinator(DataUpdateCoordinator): ) async def _async_update_data(self) -> dict[str, Any]: - data = {} service = await self._auth.get_resource() channels = self.config_entry.options[CONF_CHANNELS] channel_request: HttpRequest = service.channels().list( @@ -61,31 +59,35 @@ class YouTubeDataUpdateCoordinator(DataUpdateCoordinator): ) response: dict = await self.hass.async_add_executor_job(channel_request.execute) - async def _compile_data(channel: dict[str, Any]) -> None: + return await self.hass.async_add_executor_job( + self._get_channel_data, service, response["items"] + ) + + def _get_channel_data( + self, service: Resource, channels: list[dict[str, Any]] + ) -> dict[str, Any]: + data: dict[str, Any] = {} + for channel in channels: + playlist_id = get_upload_playlist_id(channel["id"]) + response = ( + service.playlistItems() + .list( + part="snippet,contentDetails", playlistId=playlist_id, maxResults=1 + ) + .execute() + ) + video = response["items"][0] data[channel["id"]] = { ATTR_ID: channel["id"], ATTR_TITLE: channel["snippet"]["title"], ATTR_ICON: channel["snippet"]["thumbnails"]["high"]["url"], - ATTR_LATEST_VIDEO: await self._get_latest_video(service, channel["id"]), + ATTR_LATEST_VIDEO: { + ATTR_PUBLISHED_AT: video["snippet"]["publishedAt"], + ATTR_TITLE: video["snippet"]["title"], + ATTR_DESCRIPTION: video["snippet"]["description"], + ATTR_THUMBNAIL: video["snippet"]["thumbnails"]["standard"]["url"], + ATTR_VIDEO_ID: video["contentDetails"]["videoId"], + }, ATTR_SUBSCRIBER_COUNT: int(channel["statistics"]["subscriberCount"]), } - - await asyncio.gather(*[_compile_data(channel) for channel in response["items"]]) return data - - async def _get_latest_video( - self, service: Resource, channel_id: str - ) -> dict[str, Any]: - playlist_id = get_upload_playlist_id(channel_id) - job: HttpRequest = service.playlistItems().list( - part="snippet,contentDetails", playlistId=playlist_id, maxResults=1 - ) - response: dict = await self.hass.async_add_executor_job(job.execute) - video = response["items"][0] - return { - ATTR_PUBLISHED_AT: video["snippet"]["publishedAt"], - ATTR_TITLE: video["snippet"]["title"], - ATTR_DESCRIPTION: video["snippet"]["description"], - ATTR_THUMBNAIL: video["snippet"]["thumbnails"]["standard"]["url"], - ATTR_VIDEO_ID: video["contentDetails"]["videoId"], - } From c6feb30c311a2b7fba4821f102842d9f43f1b4dc Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 28 May 2023 21:53:32 +0200 Subject: [PATCH 628/822] Raise issue "does not support platform setup" (#93585) * Raise issue on platform missing * Modify issue * Remove deprecation * Fix strings * Strings * Last strings * strings to common --- .../components/alarm_control_panel/strings.json | 6 ++++++ .../components/binary_sensor/strings.json | 6 ++++++ homeassistant/components/button/strings.json | 6 ++++++ homeassistant/components/calendar/strings.json | 6 ++++++ homeassistant/components/camera/strings.json | 6 ++++++ homeassistant/components/climate/strings.json | 6 ++++++ homeassistant/components/cover/strings.json | 6 ++++++ homeassistant/components/date/strings.json | 6 ++++++ .../components/device_tracker/strings.json | 6 ++++++ homeassistant/components/fan/strings.json | 6 ++++++ homeassistant/components/humidifier/strings.json | 6 ++++++ homeassistant/components/light/strings.json | 6 ++++++ homeassistant/components/lock/strings.json | 6 ++++++ homeassistant/components/media_player/strings.json | 6 ++++++ homeassistant/components/number/strings.json | 6 ++++++ homeassistant/components/remote/strings.json | 6 ++++++ homeassistant/components/select/strings.json | 6 ++++++ homeassistant/components/sensor/strings.json | 6 ++++++ homeassistant/components/siren/strings.json | 6 ++++++ homeassistant/components/switch/strings.json | 6 ++++++ homeassistant/components/text/strings.json | 6 ++++++ homeassistant/components/time/strings.json | 6 ++++++ homeassistant/components/update/strings.json | 6 ++++++ homeassistant/components/vacuum/strings.json | 6 ++++++ homeassistant/components/water_heater/strings.json | 6 ++++++ homeassistant/components/weather/strings.json | 6 ++++++ homeassistant/helpers/entity_platform.py | 14 ++++++++++++++ homeassistant/strings.json | 4 ++++ tests/helpers/test_entity_platform.py | 11 ++++++++++- 29 files changed, 184 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/alarm_control_panel/strings.json b/homeassistant/components/alarm_control_panel/strings.json index 6b01cab2becc..4025bbd4cc4e 100644 --- a/homeassistant/components/alarm_control_panel/strings.json +++ b/homeassistant/components/alarm_control_panel/strings.json @@ -62,5 +62,11 @@ } } } + }, + "issues": { + "platform_integration_no_support": { + "title": "[%key:common::issues::platform_integration_no_support_title%]", + "description": "[%key:common::issues::platform_integration_no_support_description%]" + } } } diff --git a/homeassistant/components/binary_sensor/strings.json b/homeassistant/components/binary_sensor/strings.json index d3045e6e8f7c..ca349e193282 100644 --- a/homeassistant/components/binary_sensor/strings.json +++ b/homeassistant/components/binary_sensor/strings.json @@ -314,5 +314,11 @@ "smoke": "smoke", "sound": "sound", "vibration": "vibration" + }, + "issues": { + "platform_integration_no_support": { + "title": "[%key:common::issues::platform_integration_no_support_title%]", + "description": "[%key:common::issues::platform_integration_no_support_description%]" + } } } diff --git a/homeassistant/components/button/strings.json b/homeassistant/components/button/strings.json index 4fd888538937..efad77f5c6df 100644 --- a/homeassistant/components/button/strings.json +++ b/homeassistant/components/button/strings.json @@ -18,5 +18,11 @@ "update": { "name": "Update" } + }, + "issues": { + "platform_integration_no_support": { + "title": "[%key:common::issues::platform_integration_no_support_title%]", + "description": "[%key:common::issues::platform_integration_no_support_description%]" + } } } diff --git a/homeassistant/components/calendar/strings.json b/homeassistant/components/calendar/strings.json index 898953c18acd..b28f741c3816 100644 --- a/homeassistant/components/calendar/strings.json +++ b/homeassistant/components/calendar/strings.json @@ -32,5 +32,11 @@ } } } + }, + "issues": { + "platform_integration_no_support": { + "title": "[%key:common::issues::platform_integration_no_support_title%]", + "description": "[%key:common::issues::platform_integration_no_support_description%]" + } } } diff --git a/homeassistant/components/camera/strings.json b/homeassistant/components/camera/strings.json index 0722ec1c5e6d..f67097516b46 100644 --- a/homeassistant/components/camera/strings.json +++ b/homeassistant/components/camera/strings.json @@ -34,5 +34,11 @@ } } } + }, + "issues": { + "platform_integration_no_support": { + "title": "[%key:common::issues::platform_integration_no_support_title%]", + "description": "[%key:common::issues::platform_integration_no_support_description%]" + } } } diff --git a/homeassistant/components/climate/strings.json b/homeassistant/components/climate/strings.json index 5e3fe15d5667..00696b0738c0 100644 --- a/homeassistant/components/climate/strings.json +++ b/homeassistant/components/climate/strings.json @@ -103,5 +103,11 @@ "temperature": { "name": "Target temperature" } } } + }, + "issues": { + "platform_integration_no_support": { + "title": "[%key:common::issues::platform_integration_no_support_title%]", + "description": "[%key:common::issues::platform_integration_no_support_description%]" + } } } diff --git a/homeassistant/components/cover/strings.json b/homeassistant/components/cover/strings.json index 2f61bd95083e..663df02a8240 100644 --- a/homeassistant/components/cover/strings.json +++ b/homeassistant/components/cover/strings.json @@ -76,5 +76,11 @@ "window": { "name": "Window" } + }, + "issues": { + "platform_integration_no_support": { + "title": "[%key:common::issues::platform_integration_no_support_title%]", + "description": "[%key:common::issues::platform_integration_no_support_description%]" + } } } diff --git a/homeassistant/components/date/strings.json b/homeassistant/components/date/strings.json index 110a4cabb921..f2d2e5ef8e18 100644 --- a/homeassistant/components/date/strings.json +++ b/homeassistant/components/date/strings.json @@ -4,5 +4,11 @@ "_": { "name": "[%key:component::date::title%]" } + }, + "issues": { + "platform_integration_no_support": { + "title": "[%key:common::issues::platform_integration_no_support_title%]", + "description": "[%key:common::issues::platform_integration_no_support_description%]" + } } } diff --git a/homeassistant/components/device_tracker/strings.json b/homeassistant/components/device_tracker/strings.json index c15b9723c972..54e4f922053a 100644 --- a/homeassistant/components/device_tracker/strings.json +++ b/homeassistant/components/device_tracker/strings.json @@ -41,5 +41,11 @@ } } } + }, + "issues": { + "platform_integration_no_support": { + "title": "[%key:common::issues::platform_integration_no_support_title%]", + "description": "[%key:common::issues::platform_integration_no_support_description%]" + } } } diff --git a/homeassistant/components/fan/strings.json b/homeassistant/components/fan/strings.json index b16d6da6df56..b69068d3d64e 100644 --- a/homeassistant/components/fan/strings.json +++ b/homeassistant/components/fan/strings.json @@ -52,5 +52,11 @@ } } } + }, + "issues": { + "platform_integration_no_support": { + "title": "[%key:common::issues::platform_integration_no_support_title%]", + "description": "[%key:common::issues::platform_integration_no_support_description%]" + } } } diff --git a/homeassistant/components/humidifier/strings.json b/homeassistant/components/humidifier/strings.json index afaa05df462c..015b3c08e9a4 100644 --- a/homeassistant/components/humidifier/strings.json +++ b/homeassistant/components/humidifier/strings.json @@ -62,5 +62,11 @@ "humidifier": { "name": "[%key:component::humidifier::entity_component::_::name%]" } + }, + "issues": { + "platform_integration_no_support": { + "title": "[%key:common::issues::platform_integration_no_support_title%]", + "description": "[%key:common::issues::platform_integration_no_support_description%]" + } } } diff --git a/homeassistant/components/light/strings.json b/homeassistant/components/light/strings.json index 935e38d33d96..f89497b5ef95 100644 --- a/homeassistant/components/light/strings.json +++ b/homeassistant/components/light/strings.json @@ -86,5 +86,11 @@ } } } + }, + "issues": { + "platform_integration_no_support": { + "title": "[%key:common::issues::platform_integration_no_support_title%]", + "description": "[%key:common::issues::platform_integration_no_support_description%]" + } } } diff --git a/homeassistant/components/lock/strings.json b/homeassistant/components/lock/strings.json index da4b5217b862..b77bf5e6900a 100644 --- a/homeassistant/components/lock/strings.json +++ b/homeassistant/components/lock/strings.json @@ -34,5 +34,11 @@ } } } + }, + "issues": { + "platform_integration_no_support": { + "title": "[%key:common::issues::platform_integration_no_support_title%]", + "description": "[%key:common::issues::platform_integration_no_support_description%]" + } } } diff --git a/homeassistant/components/media_player/strings.json b/homeassistant/components/media_player/strings.json index cee0ee200fe9..4c33d1f27ef6 100644 --- a/homeassistant/components/media_player/strings.json +++ b/homeassistant/components/media_player/strings.json @@ -159,5 +159,11 @@ "receiver": { "name": "Receiver" } + }, + "issues": { + "platform_integration_no_support": { + "title": "[%key:common::issues::platform_integration_no_support_title%]", + "description": "[%key:common::issues::platform_integration_no_support_description%]" + } } } diff --git a/homeassistant/components/number/strings.json b/homeassistant/components/number/strings.json index 46db471305c3..9af54311129f 100644 --- a/homeassistant/components/number/strings.json +++ b/homeassistant/components/number/strings.json @@ -154,5 +154,11 @@ "wind_speed": { "name": "[%key:component::sensor::entity_component::wind_speed::name%]" } + }, + "issues": { + "platform_integration_no_support": { + "title": "[%key:common::issues::platform_integration_no_support_title%]", + "description": "[%key:common::issues::platform_integration_no_support_description%]" + } } } diff --git a/homeassistant/components/remote/strings.json b/homeassistant/components/remote/strings.json index f0d2787b6586..18a924942426 100644 --- a/homeassistant/components/remote/strings.json +++ b/homeassistant/components/remote/strings.json @@ -24,5 +24,11 @@ "on": "[%key:common::state::on%]" } } + }, + "issues": { + "platform_integration_no_support": { + "title": "[%key:common::issues::platform_integration_no_support_title%]", + "description": "[%key:common::issues::platform_integration_no_support_description%]" + } } } diff --git a/homeassistant/components/select/strings.json b/homeassistant/components/select/strings.json index 9080b940b2a5..53441d365b4f 100644 --- a/homeassistant/components/select/strings.json +++ b/homeassistant/components/select/strings.json @@ -24,5 +24,11 @@ } } } + }, + "issues": { + "platform_integration_no_support": { + "title": "[%key:common::issues::platform_integration_no_support_title%]", + "description": "[%key:common::issues::platform_integration_no_support_description%]" + } } } diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index c4c1f81109d4..d3dbbc678b07 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -267,5 +267,11 @@ "wind_speed": { "name": "Wind speed" } + }, + "issues": { + "platform_integration_no_support": { + "title": "[%key:common::issues::platform_integration_no_support_title%]", + "description": "[%key:common::issues::platform_integration_no_support_description%]" + } } } diff --git a/homeassistant/components/siren/strings.json b/homeassistant/components/siren/strings.json index 60d8843c1515..c3dde16a99fe 100644 --- a/homeassistant/components/siren/strings.json +++ b/homeassistant/components/siren/strings.json @@ -13,5 +13,11 @@ } } } + }, + "issues": { + "platform_integration_no_support": { + "title": "[%key:common::issues::platform_integration_no_support_title%]", + "description": "[%key:common::issues::platform_integration_no_support_description%]" + } } } diff --git a/homeassistant/components/switch/strings.json b/homeassistant/components/switch/strings.json index a7934ba42092..2bb6c82a8c15 100644 --- a/homeassistant/components/switch/strings.json +++ b/homeassistant/components/switch/strings.json @@ -30,5 +30,11 @@ "outlet": { "name": "Outlet" } + }, + "issues": { + "platform_integration_no_support": { + "title": "[%key:common::issues::platform_integration_no_support_title%]", + "description": "[%key:common::issues::platform_integration_no_support_description%]" + } } } diff --git a/homeassistant/components/text/strings.json b/homeassistant/components/text/strings.json index 034f1ab315b8..d8f55dbe4e79 100644 --- a/homeassistant/components/text/strings.json +++ b/homeassistant/components/text/strings.json @@ -27,5 +27,11 @@ } } } + }, + "issues": { + "platform_integration_no_support": { + "title": "[%key:common::issues::platform_integration_no_support_title%]", + "description": "[%key:common::issues::platform_integration_no_support_description%]" + } } } diff --git a/homeassistant/components/time/strings.json b/homeassistant/components/time/strings.json index e8d92a30e2ea..9cbcf718d736 100644 --- a/homeassistant/components/time/strings.json +++ b/homeassistant/components/time/strings.json @@ -4,5 +4,11 @@ "_": { "name": "[%key:component::time::title%]" } + }, + "issues": { + "platform_integration_no_support": { + "title": "[%key:common::issues::platform_integration_no_support_title%]", + "description": "[%key:common::issues::platform_integration_no_support_description%]" + } } } diff --git a/homeassistant/components/update/strings.json b/homeassistant/components/update/strings.json index 776c2c59a3a7..4d730d71721b 100644 --- a/homeassistant/components/update/strings.json +++ b/homeassistant/components/update/strings.json @@ -14,5 +14,11 @@ "firmware": { "name": "firmware" } + }, + "issues": { + "platform_integration_no_support": { + "title": "[%key:common::issues::platform_integration_no_support_title%]", + "description": "[%key:common::issues::platform_integration_no_support_description%]" + } } } diff --git a/homeassistant/components/vacuum/strings.json b/homeassistant/components/vacuum/strings.json index a27a60bba4f3..e0db3ba4e47d 100644 --- a/homeassistant/components/vacuum/strings.json +++ b/homeassistant/components/vacuum/strings.json @@ -28,5 +28,11 @@ "returning": "Returning to dock" } } + }, + "issues": { + "platform_integration_no_support": { + "title": "[%key:common::issues::platform_integration_no_support_title%]", + "description": "[%key:common::issues::platform_integration_no_support_description%]" + } } } diff --git a/homeassistant/components/water_heater/strings.json b/homeassistant/components/water_heater/strings.json index 6344b5a847a2..b07842796673 100644 --- a/homeassistant/components/water_heater/strings.json +++ b/homeassistant/components/water_heater/strings.json @@ -18,5 +18,11 @@ "performance": "Performance" } } + }, + "issues": { + "platform_integration_no_support": { + "title": "[%key:common::issues::platform_integration_no_support_title%]", + "description": "[%key:common::issues::platform_integration_no_support_description%]" + } } } diff --git a/homeassistant/components/weather/strings.json b/homeassistant/components/weather/strings.json index a64f84672242..461f715c8dbd 100644 --- a/homeassistant/components/weather/strings.json +++ b/homeassistant/components/weather/strings.json @@ -62,5 +62,11 @@ } } } + }, + "issues": { + "platform_integration_no_support": { + "title": "[%key:common::issues::platform_integration_no_support_title%]", + "description": "[%key:common::issues::platform_integration_no_support_description%]" + } } } diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 52d436ca997e..f7793a4329c4 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -44,6 +44,7 @@ from . import ( from .device_registry import DeviceRegistry from .entity_registry import EntityRegistry, RegistryEntryDisabler, RegistryEntryHider from .event import async_call_later, async_track_time_interval +from .issue_registry import IssueSeverity, async_create_issue from .typing import ConfigType, DiscoveryInfoType if TYPE_CHECKING: @@ -211,6 +212,19 @@ class EntityPlatform: self.platform_name, self.domain, ) + async_create_issue( + self.hass, + self.domain, + f"platform_integration_no_support_{self.domain}_{self.platform_name}", + is_fixable=False, + severity=IssueSeverity.ERROR, + translation_key="platform_integration_no_support", + translation_placeholders={ + "domain": self.domain, + "platform": self.platform_name, + }, + ) + return @callback diff --git a/homeassistant/strings.json b/homeassistant/strings.json index c4cf0593aaeb..4da9c25ca107 100644 --- a/homeassistant/strings.json +++ b/homeassistant/strings.json @@ -87,6 +87,10 @@ "unknown_authorize_url_generation": "Unknown error generating an authorize URL.", "cloud_not_connected": "Not connected to Home Assistant Cloud." } + }, + "issues": { + "platform_integration_no_support_title": "Platform support not supported", + "platform_integration_no_support_description": "The {platform} platform for the {domain} integration does not support platform setup.\n\nPlease remove it from your configuration and restart Home Assistant to fix this issue." } } } diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 346a124424ef..dd39f664f7ae 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -14,6 +14,7 @@ from homeassistant.helpers import ( device_registry as dr, entity_platform, entity_registry as er, + issue_registry as ir, ) from homeassistant.helpers.entity import ( DeviceInfo, @@ -1458,7 +1459,9 @@ async def test_override_restored_entities( async def test_platform_with_no_setup( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + issue_registry: ir.IssueRegistry, ) -> None: """Test setting up a platform that does not support setup.""" entity_platform = MockEntityPlatform( @@ -1471,6 +1474,12 @@ async def test_platform_with_no_setup( "The mock-platform platform for the mock-integration integration does not support platform setup." in caplog.text ) + issue = issue_registry.async_get_issue( + domain="mock-integration", + issue_id="platform_integration_no_support_mock-integration_mock-platform", + ) + assert issue + assert issue.translation_key == "platform_integration_no_support" async def test_platforms_sharing_services(hass: HomeAssistant) -> None: From 5f584d5dbd04c694be5d56c709a8dc02a864dc71 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sun, 28 May 2023 22:39:41 +0200 Subject: [PATCH 629/822] Fix typo in switcher_kis (#93702) --- homeassistant/components/switcher_kis/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/switcher_kis/__init__.py b/homeassistant/components/switcher_kis/__init__.py index abb18a19ed32..fb6ded993467 100644 --- a/homeassistant/components/switcher_kis/__init__.py +++ b/homeassistant/components/switcher_kis/__init__.py @@ -39,7 +39,7 @@ PLATFORMS = [ _LOGGER = logging.getLogger(__name__) -CCONFIG_SCHEMA = vol.Schema( +CONFIG_SCHEMA = vol.Schema( vol.All( cv.deprecated(DOMAIN), { From 795ef075da13b1594f40fa5c4e4a14873c111ff7 Mon Sep 17 00:00:00 2001 From: Marvin Wichmann Date: Mon, 29 May 2023 03:40:35 +0200 Subject: [PATCH 630/822] Restore original websocket commands for config entries (#93707) Restore original websocket commands and add "config_entries/get_single" --- .../components/config/config_entries.py | 12 +++++----- .../components/bluetooth/test_config_flow.py | 6 ++--- .../components/config/test_config_entries.py | 22 ++++++++++--------- tests/components/shelly/test_config_flow.py | 6 ++--- 4 files changed, 24 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index 2e1ea72dfb45..d58616ff38f4 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -41,9 +41,9 @@ async def async_setup(hass): hass.http.register_view(OptionManagerFlowIndexView(hass.config_entries.options)) hass.http.register_view(OptionManagerFlowResourceView(hass.config_entries.options)) - websocket_api.async_register_command(hass, config_entries_get_matching) + websocket_api.async_register_command(hass, config_entries_get) websocket_api.async_register_command(hass, config_entry_disable) - websocket_api.async_register_command(hass, config_entry_get) + websocket_api.async_register_command(hass, config_entry_get_single) websocket_api.async_register_command(hass, config_entry_update) websocket_api.async_register_command(hass, config_entries_subscribe) websocket_api.async_register_command(hass, config_entries_progress) @@ -288,12 +288,12 @@ def get_entry( @websocket_api.require_admin @websocket_api.websocket_command( { - "type": "config_entries/get", + "type": "config_entries/get_single", "entry_id": str, } ) @websocket_api.async_response -async def config_entry_get( +async def config_entry_get_single( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], @@ -432,13 +432,13 @@ async def ignore_config_flow( @websocket_api.websocket_command( { - vol.Required("type"): "config_entries/get_matching", + vol.Required("type"): "config_entries/get", vol.Optional("type_filter"): vol.All(cv.ensure_list, [str]), vol.Optional("domain"): str, } ) @websocket_api.async_response -async def config_entries_get_matching( +async def config_entries_get( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], diff --git a/tests/components/bluetooth/test_config_flow.py b/tests/components/bluetooth/test_config_flow.py index 16fe9602d39e..1489f349cf40 100644 --- a/tests/components/bluetooth/test_config_flow.py +++ b/tests/components/bluetooth/test_config_flow.py @@ -36,7 +36,7 @@ async def test_options_flow_disabled_not_setup( await ws_client.send_json( { "id": 5, - "type": "config_entries/get_matching", + "type": "config_entries/get", "domain": "bluetooth", } ) @@ -370,7 +370,7 @@ async def test_options_flow_disabled_macos( await ws_client.send_json( { "id": 5, - "type": "config_entries/get_matching", + "type": "config_entries/get", "domain": "bluetooth", } ) @@ -403,7 +403,7 @@ async def test_options_flow_enabled_linux( await ws_client.send_json( { "id": 5, - "type": "config_entries/get_matching", + "type": "config_entries/get", "domain": "bluetooth", } ) diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index bbc6ce210cb1..efd9617c4917 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -969,7 +969,9 @@ async def test_options_flow_with_invalid_data(hass: HomeAssistant, client) -> No } -async def test_get(hass: HomeAssistant, hass_ws_client: WebSocketGenerator) -> None: +async def test_get_single( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: """Test that we can get a config entry.""" assert await async_setup_component(hass, "config", {}) ws_client = await hass_ws_client(hass) @@ -982,7 +984,7 @@ async def test_get(hass: HomeAssistant, hass_ws_client: WebSocketGenerator) -> N await ws_client.send_json_auto_id( { - "type": "config_entries/get", + "type": "config_entries/get_single", "entry_id": entry.entry_id, } ) @@ -1006,7 +1008,7 @@ async def test_get(hass: HomeAssistant, hass_ws_client: WebSocketGenerator) -> N await ws_client.send_json_auto_id( { - "type": "config_entries/get", + "type": "config_entries/get_single", "entry_id": "blah", } ) @@ -1317,7 +1319,7 @@ async def test_get_matching_entries_ws( ws_client = await hass_ws_client(hass) - await ws_client.send_json_auto_id({"type": "config_entries/get_matching"}) + await ws_client.send_json_auto_id({"type": "config_entries/get"}) response = await ws_client.receive_json() assert response["result"] == [ { @@ -1394,7 +1396,7 @@ async def test_get_matching_entries_ws( await ws_client.send_json_auto_id( { - "type": "config_entries/get_matching", + "type": "config_entries/get", "domain": "comp1", "type_filter": "hub", } @@ -1419,7 +1421,7 @@ async def test_get_matching_entries_ws( await ws_client.send_json_auto_id( { - "type": "config_entries/get_matching", + "type": "config_entries/get", "type_filter": ["service", "device"], } ) @@ -1457,7 +1459,7 @@ async def test_get_matching_entries_ws( await ws_client.send_json_auto_id( { - "type": "config_entries/get_matching", + "type": "config_entries/get", "type_filter": "hub", } ) @@ -1500,7 +1502,7 @@ async def test_get_matching_entries_ws( ): await ws_client.send_json_auto_id( { - "type": "config_entries/get_matching", + "type": "config_entries/get", "type_filter": "hub", } ) @@ -1586,7 +1588,7 @@ async def test_get_matching_entries_ws( ): await ws_client.send_json_auto_id( { - "type": "config_entries/get_matching", + "type": "config_entries/get", "type_filter": ["helper"], } ) @@ -1602,7 +1604,7 @@ async def test_get_matching_entries_ws( ): await ws_client.send_json_auto_id( { - "type": "config_entries/get_matching", + "type": "config_entries/get", "type_filter": ["device", "hub", "service"], } ) diff --git a/tests/components/shelly/test_config_flow.py b/tests/components/shelly/test_config_flow.py index 210db65dfda8..621885bef085 100644 --- a/tests/components/shelly/test_config_flow.py +++ b/tests/components/shelly/test_config_flow.py @@ -858,7 +858,7 @@ async def test_options_flow_disabled_gen_1( await ws_client.send_json( { "id": 5, - "type": "config_entries/get_matching", + "type": "config_entries/get", "domain": "shelly", } ) @@ -879,7 +879,7 @@ async def test_options_flow_enabled_gen_2( await ws_client.send_json( { "id": 5, - "type": "config_entries/get_matching", + "type": "config_entries/get", "domain": "shelly", } ) @@ -900,7 +900,7 @@ async def test_options_flow_disabled_sleepy_gen_2( await ws_client.send_json( { "id": 5, - "type": "config_entries/get_matching", + "type": "config_entries/get", "domain": "shelly", } ) From 20d1a0fc7745e44f1ca5521b6a558a48a9cb7189 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 29 May 2023 03:44:45 +0200 Subject: [PATCH 631/822] Add Options flow to YouTube (#93667) * Add Options flow to YouTube * Add strings for options flow * Add strings for options flow * Add strings for options flow --- .../components/youtube/config_flow.py | 80 ++++++++++++++++--- homeassistant/components/youtube/strings.json | 10 +++ tests/components/youtube/test_config_flow.py | 34 +++++++- 3 files changed, 112 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/youtube/config_flow.py b/homeassistant/components/youtube/config_flow.py index 3d7748e9b572..a2adebc84af0 100644 --- a/homeassistant/components/youtube/config_flow.py +++ b/homeassistant/components/youtube/config_flow.py @@ -11,8 +11,9 @@ from googleapiclient.errors import HttpError from googleapiclient.http import HttpRequest import voluptuous as vol -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, OptionsFlowWithConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN +from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.helpers.selector import ( @@ -24,6 +25,19 @@ from homeassistant.helpers.selector import ( from .const import CONF_CHANNELS, DEFAULT_ACCESS, DOMAIN, LOGGER +async def get_resource(hass: HomeAssistant, token: str) -> Resource: + """Get Youtube resource async.""" + + def _build_resource() -> Resource: + return build( + "youtube", + "v3", + credentials=Credentials(token), + ) + + return await hass.async_add_executor_job(_build_resource) + + class OAuth2FlowHandler( config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN ): @@ -36,6 +50,14 @@ class OAuth2FlowHandler( reauth_entry: ConfigEntry | None = None + @staticmethod + @callback + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> YouTubeOptionsFlowHandler: + """Get the options flow for this handler.""" + return YouTubeOptionsFlowHandler(config_entry) + @property def logger(self) -> logging.Logger: """Return logger.""" @@ -69,7 +91,7 @@ class OAuth2FlowHandler( async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult: """Create an entry for the flow, or update existing entry.""" try: - service = await self._get_resource(data[CONF_TOKEN][CONF_ACCESS_TOKEN]) + service = await get_resource(self.hass, data[CONF_TOKEN][CONF_ACCESS_TOKEN]) # pylint: disable=no-member own_channel_request: HttpRequest = service.channels().list( part="snippet", mine=True @@ -116,7 +138,9 @@ class OAuth2FlowHandler( data=self._data, options=user_input, ) - service = await self._get_resource(self._data[CONF_TOKEN][CONF_ACCESS_TOKEN]) + service = await get_resource( + self.hass, self._data[CONF_TOKEN][CONF_ACCESS_TOKEN] + ) # pylint: disable=no-member subscription_request: HttpRequest = service.subscriptions().list( part="snippet", mine=True, maxResults=50 @@ -140,12 +164,46 @@ class OAuth2FlowHandler( ), ) - async def _get_resource(self, token: str) -> Resource: - def _build_resource() -> Resource: - return build( - "youtube", - "v3", - credentials=Credentials(token), - ) - return await self.hass.async_add_executor_job(_build_resource) +class YouTubeOptionsFlowHandler(OptionsFlowWithConfigEntry): + """YouTube 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( + title=self.config_entry.title, + data=user_input, + ) + service = await get_resource( + self.hass, self.config_entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN] + ) + # pylint: disable=no-member + subscription_request: HttpRequest = service.subscriptions().list( + part="snippet", mine=True, maxResults=50 + ) + response = await self.hass.async_add_executor_job(subscription_request.execute) + selectable_channels = [ + SelectOptionDict( + value=subscription["snippet"]["resourceId"]["channelId"], + label=subscription["snippet"]["title"], + ) + for subscription in response["items"] + ] + return self.async_show_form( + step_id="init", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_CHANNELS): SelectSelector( + SelectSelectorConfig( + options=selectable_channels, multiple=True + ) + ), + } + ), + self.options, + ), + ) diff --git a/homeassistant/components/youtube/strings.json b/homeassistant/components/youtube/strings.json index f9a2d3af0ae3..24369ab26f97 100644 --- a/homeassistant/components/youtube/strings.json +++ b/homeassistant/components/youtube/strings.json @@ -19,6 +19,16 @@ } } }, + "options": { + "step": { + "init": { + "description": "Select the channels you want to add.", + "data": { + "channels": "YouTube channels" + } + } + } + }, "entity": { "sensor": { "latest_upload": { diff --git a/tests/components/youtube/test_config_flow.py b/tests/components/youtube/test_config_flow.py index debe25c174f1..ed33947b5930 100644 --- a/tests/components/youtube/test_config_flow.py +++ b/tests/components/youtube/test_config_flow.py @@ -12,7 +12,14 @@ from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow from . import MockService -from .conftest import CLIENT_ID, GOOGLE_AUTH_URI, GOOGLE_TOKEN_URI, SCOPES, TITLE +from .conftest import ( + CLIENT_ID, + GOOGLE_AUTH_URI, + GOOGLE_TOKEN_URI, + SCOPES, + TITLE, + ComponentSetup, +) from tests.common import MockConfigEntry, load_fixture from tests.test_util.aiohttp import AiohttpClientMocker @@ -267,3 +274,28 @@ async def test_flow_exception( result = await hass.config_entries.flow.async_configure(result["flow_id"]) assert result["type"] == FlowResultType.ABORT assert result["reason"] == "unknown" + + +async def test_options_flow( + hass: HomeAssistant, setup_integration: ComponentSetup +) -> None: + """Test the full options flow.""" + await setup_integration() + with patch( + "homeassistant.components.youtube.config_flow.build", return_value=MockService() + ): + entry = hass.config_entries.async_entries(DOMAIN)[0] + result = await hass.config_entries.options.async_init(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_CHANNELS: ["UC_x5XG1OV2P6uZZ5FSM9Ttw"]}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_CHANNELS: ["UC_x5XG1OV2P6uZZ5FSM9Ttw"]} From 46e7486ce6b6e87312690e6a2e70c4e74ed7aaff Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 29 May 2023 08:00:50 +0200 Subject: [PATCH 632/822] Move yaml configuration to integration key for command_line (#92824) * Inital init commit * bs and cover * notify * sensor * switch * Issues * Finalize __init__ * First pass tests * Fix Binary sensors * Test cover * Test notify * Test sensor * Tests switch * Fix coverage * Add codeowner * Fix caplog * test issue * Flaky test notify * Fix async * Adjust yaml structure * Change yaml format again * Issue strings * Fix tests * string review comment --- CODEOWNERS | 2 + .../components/command_line/__init__.py | 176 +++++++ .../components/command_line/binary_sensor.py | 37 +- .../components/command_line/cover.py | 38 +- .../components/command_line/manifest.json | 2 +- .../components/command_line/notify.py | 26 +- .../components/command_line/sensor.py | 34 +- .../components/command_line/strings.json | 8 + .../components/command_line/switch.py | 38 +- tests/components/command_line/conftest.py | 72 +++ .../command_line/test_binary_sensor.py | 184 ++++--- tests/components/command_line/test_cover.py | 260 +++++++--- tests/components/command_line/test_init.py | 27 + tests/components/command_line/test_notify.py | 150 ++++-- tests/components/command_line/test_sensor.py | 479 +++++++++++++----- tests/components/command_line/test_switch.py | 417 ++++++++++----- 16 files changed, 1461 insertions(+), 489 deletions(-) create mode 100644 homeassistant/components/command_line/strings.json create mode 100644 tests/components/command_line/conftest.py create mode 100644 tests/components/command_line/test_init.py diff --git a/CODEOWNERS b/CODEOWNERS index f01a1173b20e..763c47840d3d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -213,6 +213,8 @@ build.json @home-assistant/supervisor /tests/components/color_extractor/ @GenericStudent /homeassistant/components/comfoconnect/ @michaelarnauts /tests/components/comfoconnect/ @michaelarnauts +/homeassistant/components/command_line/ @gjohansson-ST +/tests/components/command_line/ @gjohansson-ST /homeassistant/components/compensation/ @Petro31 /tests/components/compensation/ @Petro31 /homeassistant/components/config/ @home-assistant/core diff --git a/homeassistant/components/command_line/__init__.py b/homeassistant/components/command_line/__init__.py index fe0640d3efa7..c34b35bb62f9 100644 --- a/homeassistant/components/command_line/__init__.py +++ b/homeassistant/components/command_line/__init__.py @@ -1 +1,177 @@ """The command_line component.""" +from __future__ import annotations + +import asyncio +from collections.abc import Coroutine +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASSES_SCHEMA as BINARY_SENSOR_DEVICE_CLASSES_SCHEMA, + DOMAIN as BINARY_SENSOR_DOMAIN, +) +from homeassistant.components.cover import DOMAIN as COVER_DOMAIN +from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN +from homeassistant.components.sensor import ( + CONF_STATE_CLASS, + DEVICE_CLASSES_SCHEMA as SENSOR_DEVICE_CLASSES_SCHEMA, + DOMAIN as SENSOR_DOMAIN, + STATE_CLASSES_SCHEMA as SENSOR_STATE_CLASSES_SCHEMA, +) +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import ( + CONF_COMMAND, + CONF_COMMAND_CLOSE, + CONF_COMMAND_OFF, + CONF_COMMAND_ON, + CONF_COMMAND_OPEN, + CONF_COMMAND_STATE, + CONF_COMMAND_STOP, + CONF_DEVICE_CLASS, + CONF_ICON, + CONF_NAME, + CONF_PAYLOAD_OFF, + CONF_PAYLOAD_ON, + CONF_UNIQUE_ID, + CONF_UNIT_OF_MEASUREMENT, + CONF_VALUE_TEMPLATE, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import discovery +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.reload import async_setup_reload_service +from homeassistant.helpers.typing import ConfigType + +from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN + +BINARY_SENSOR_DEFAULT_NAME = "Binary Command Sensor" +DEFAULT_PAYLOAD_ON = "ON" +DEFAULT_PAYLOAD_OFF = "OFF" +CONF_JSON_ATTRIBUTES = "json_attributes" +SENSOR_DEFAULT_NAME = "Command Sensor" +CONF_NOTIFIERS = "notifiers" + +PLATFORM_MAPPING = { + BINARY_SENSOR_DOMAIN: Platform.BINARY_SENSOR, + COVER_DOMAIN: Platform.COVER, + NOTIFY_DOMAIN: Platform.NOTIFY, + SENSOR_DOMAIN: Platform.SENSOR, + SWITCH_DOMAIN: Platform.SWITCH, +} + +_LOGGER = logging.getLogger(__name__) + +BINARY_SENSOR_SCHEMA = vol.Schema( + { + vol.Required(CONF_COMMAND): cv.string, + vol.Optional(CONF_NAME, default=BINARY_SENSOR_DEFAULT_NAME): cv.string, + vol.Optional(CONF_PAYLOAD_OFF, default=DEFAULT_PAYLOAD_OFF): cv.string, + vol.Optional(CONF_PAYLOAD_ON, default=DEFAULT_PAYLOAD_ON): cv.string, + vol.Optional(CONF_DEVICE_CLASS): BINARY_SENSOR_DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, + vol.Optional(CONF_UNIQUE_ID): cv.string, + } +) +COVER_SCHEMA = vol.Schema( + { + vol.Optional(CONF_COMMAND_CLOSE, default="true"): cv.string, + vol.Optional(CONF_COMMAND_OPEN, default="true"): cv.string, + vol.Optional(CONF_COMMAND_STATE): cv.string, + vol.Optional(CONF_COMMAND_STOP, default="true"): cv.string, + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, + vol.Optional(CONF_UNIQUE_ID): cv.string, + } +) +NOTIFY_SCHEMA = vol.Schema( + { + vol.Required(CONF_COMMAND): cv.string, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, + } +) +SENSOR_SCHEMA = vol.Schema( + { + vol.Required(CONF_COMMAND): cv.string, + vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, + vol.Optional(CONF_JSON_ATTRIBUTES): cv.ensure_list_csv, + vol.Optional(CONF_NAME, default=SENSOR_DEFAULT_NAME): cv.string, + vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional(CONF_DEVICE_CLASS): SENSOR_DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_STATE_CLASS): SENSOR_STATE_CLASSES_SCHEMA, + } +) +SWITCH_SCHEMA = vol.Schema( + { + vol.Optional(CONF_COMMAND_OFF, default="true"): cv.string, + vol.Optional(CONF_COMMAND_ON, default="true"): cv.string, + vol.Optional(CONF_COMMAND_STATE): cv.string, + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_ICON): cv.template, + vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, + vol.Optional(CONF_UNIQUE_ID): cv.string, + } +) +COMBINED_SCHEMA = vol.Schema( + { + vol.Optional(BINARY_SENSOR_DOMAIN): BINARY_SENSOR_SCHEMA, + vol.Optional(COVER_DOMAIN): COVER_SCHEMA, + vol.Optional(NOTIFY_DOMAIN): NOTIFY_SCHEMA, + vol.Optional(SENSOR_DOMAIN): SENSOR_SCHEMA, + vol.Optional(SWITCH_DOMAIN): SWITCH_SCHEMA, + } +) +CONFIG_SCHEMA = vol.Schema( + { + vol.Optional(DOMAIN): vol.All( + cv.ensure_list, + [COMBINED_SCHEMA], + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up Command Line from yaml config.""" + command_line_config: list[dict[str, dict[str, Any]]] = config.get(DOMAIN, {}) + if not command_line_config: + return True + + _LOGGER.debug("Full config loaded: %s", command_line_config) + + load_coroutines: list[Coroutine[Any, Any, None]] = [] + platforms: list[Platform] = [] + for platform_config in command_line_config: + for platform, _config in platform_config.items(): + platforms.append(PLATFORM_MAPPING[platform]) + _LOGGER.debug( + "Loading config %s for platform %s", + platform_config, + PLATFORM_MAPPING[platform], + ) + load_coroutines.append( + discovery.async_load_platform( + hass, + PLATFORM_MAPPING[platform], + DOMAIN, + _config, + config, + ) + ) + + await async_setup_reload_service(hass, DOMAIN, platforms) + + if load_coroutines: + _LOGGER.debug("Loading platforms: %s", platforms) + await asyncio.gather(*load_coroutines) + + return True diff --git a/homeassistant/components/command_line/binary_sensor.py b/homeassistant/components/command_line/binary_sensor.py index 0c2edb8f1912..18b3cf71eb03 100644 --- a/homeassistant/components/command_line/binary_sensor.py +++ b/homeassistant/components/command_line/binary_sensor.py @@ -7,6 +7,7 @@ import voluptuous as vol from homeassistant.components.binary_sensor import ( DEVICE_CLASSES_SCHEMA, + DOMAIN as BINARY_SENSOR_DOMAIN, PLATFORM_SCHEMA, BinarySensorDeviceClass, BinarySensorEntity, @@ -23,11 +24,11 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.reload import async_setup_reload_service +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, PLATFORMS +from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN from .sensor import CommandSensorData DEFAULT_NAME = "Binary Command Sensor" @@ -59,16 +60,30 @@ async def async_setup_platform( ) -> None: """Set up the Command line Binary Sensor.""" - await async_setup_reload_service(hass, DOMAIN, PLATFORMS) + if binary_sensor_config := config: + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml_binary_sensor", + breaks_in_ha_version="2023.8.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_platform_yaml", + translation_placeholders={"platform": BINARY_SENSOR_DOMAIN}, + ) + if discovery_info: + binary_sensor_config = discovery_info - name: str = config.get(CONF_NAME, DEFAULT_NAME) - command: str = config[CONF_COMMAND] - payload_off: str = config[CONF_PAYLOAD_OFF] - payload_on: str = config[CONF_PAYLOAD_ON] - device_class: BinarySensorDeviceClass | None = config.get(CONF_DEVICE_CLASS) - value_template: Template | None = config.get(CONF_VALUE_TEMPLATE) - command_timeout: int = config[CONF_COMMAND_TIMEOUT] - unique_id: str | None = config.get(CONF_UNIQUE_ID) + name: str = binary_sensor_config.get(CONF_NAME, DEFAULT_NAME) + command: str = binary_sensor_config[CONF_COMMAND] + payload_off: str = binary_sensor_config[CONF_PAYLOAD_OFF] + payload_on: str = binary_sensor_config[CONF_PAYLOAD_ON] + device_class: BinarySensorDeviceClass | None = binary_sensor_config.get( + CONF_DEVICE_CLASS + ) + value_template: Template | None = binary_sensor_config.get(CONF_VALUE_TEMPLATE) + command_timeout: int = binary_sensor_config[CONF_COMMAND_TIMEOUT] + unique_id: str | None = binary_sensor_config.get(CONF_UNIQUE_ID) if value_template is not None: value_template.hass = hass data = CommandSensorData(hass, command, command_timeout) diff --git a/homeassistant/components/command_line/cover.py b/homeassistant/components/command_line/cover.py index e477affc8541..4503ceb8e569 100644 --- a/homeassistant/components/command_line/cover.py +++ b/homeassistant/components/command_line/cover.py @@ -6,7 +6,11 @@ from typing import TYPE_CHECKING, Any import voluptuous as vol -from homeassistant.components.cover import PLATFORM_SCHEMA, CoverEntity +from homeassistant.components.cover import ( + DOMAIN as COVER_DOMAIN, + PLATFORM_SCHEMA, + CoverEntity, +) from homeassistant.const import ( CONF_COMMAND_CLOSE, CONF_COMMAND_OPEN, @@ -14,17 +18,19 @@ from homeassistant.const import ( CONF_COMMAND_STOP, CONF_COVERS, CONF_FRIENDLY_NAME, + CONF_NAME, CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, ) from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.reload import async_setup_reload_service +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.util import slugify -from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, PLATFORMS +from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN from .utils import call_shell_with_timeout, check_output_or_log _LOGGER = logging.getLogger(__name__) @@ -55,19 +61,35 @@ async def async_setup_platform( ) -> None: """Set up cover controlled by shell commands.""" - await async_setup_reload_service(hass, DOMAIN, PLATFORMS) - - devices: dict[str, Any] = config.get(CONF_COVERS, {}) covers = [] + if discovery_info: + entities: dict[str, Any] = {slugify(discovery_info[CONF_NAME]): discovery_info} + else: + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml_cover", + breaks_in_ha_version="2023.8.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_platform_yaml", + translation_placeholders={"platform": COVER_DOMAIN}, + ) + entities = config.get(CONF_COVERS, {}) - for device_name, device_config in devices.items(): + for device_name, device_config in entities.items(): value_template: Template | None = device_config.get(CONF_VALUE_TEMPLATE) if value_template is not None: value_template.hass = hass + if name := device_config.get( + CONF_FRIENDLY_NAME + ): # Backward compatibility. Can be removed after deprecation + device_config[CONF_NAME] = name + covers.append( CommandCover( - device_config.get(CONF_FRIENDLY_NAME, device_name), + device_config.get(CONF_NAME, device_name), device_config[CONF_COMMAND_OPEN], device_config[CONF_COMMAND_CLOSE], device_config[CONF_COMMAND_STOP], diff --git a/homeassistant/components/command_line/manifest.json b/homeassistant/components/command_line/manifest.json index 998c02aad9ec..e99234bed1b9 100644 --- a/homeassistant/components/command_line/manifest.json +++ b/homeassistant/components/command_line/manifest.json @@ -1,7 +1,7 @@ { "domain": "command_line", "name": "Command Line", - "codeowners": [], + "codeowners": ["@gjohansson-ST"], "documentation": "https://www.home-assistant.io/integrations/command_line", "iot_class": "local_polling" } diff --git a/homeassistant/components/command_line/notify.py b/homeassistant/components/command_line/notify.py index 412456ff6e5f..2922b8caae3b 100644 --- a/homeassistant/components/command_line/notify.py +++ b/homeassistant/components/command_line/notify.py @@ -7,14 +7,19 @@ from typing import Any import voluptuous as vol -from homeassistant.components.notify import PLATFORM_SCHEMA, BaseNotificationService +from homeassistant.components.notify import ( + DOMAIN as NOTIFY_DOMAIN, + PLATFORM_SCHEMA, + BaseNotificationService, +) from homeassistant.const import CONF_COMMAND, CONF_NAME from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.issue_registry import IssueSeverity, create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.process import kill_subprocess -from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT +from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -33,8 +38,21 @@ def get_service( discovery_info: DiscoveryInfoType | None = None, ) -> CommandLineNotificationService: """Get the Command Line notification service.""" - command: str = config[CONF_COMMAND] - timeout: int = config[CONF_COMMAND_TIMEOUT] + if notify_config := config: + create_issue( + hass, + DOMAIN, + "deprecated_yaml_notify", + breaks_in_ha_version="2023.8.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_platform_yaml", + translation_placeholders={"platform": NOTIFY_DOMAIN}, + ) + if discovery_info: + notify_config = discovery_info + command: str = notify_config[CONF_COMMAND] + timeout: int = notify_config[CONF_COMMAND_TIMEOUT] return CommandLineNotificationService(command, timeout) diff --git a/homeassistant/components/command_line/sensor.py b/homeassistant/components/command_line/sensor.py index b6a2b8d83faa..1689b136f2fc 100644 --- a/homeassistant/components/command_line/sensor.py +++ b/homeassistant/components/command_line/sensor.py @@ -11,6 +11,7 @@ import voluptuous as vol from homeassistant.components.sensor import ( CONF_STATE_CLASS, DEVICE_CLASSES_SCHEMA, + DOMAIN as SENSOR_DOMAIN, PLATFORM_SCHEMA, STATE_CLASSES_SCHEMA, SensorEntity, @@ -27,11 +28,11 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import TemplateError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.reload import async_setup_reload_service +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, PLATFORMS +from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN from .utils import check_output_or_log _LOGGER = logging.getLogger(__name__) @@ -64,18 +65,29 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Command Sensor.""" + if sensor_config := config: + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml_sensor", + breaks_in_ha_version="2023.8.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_platform_yaml", + translation_placeholders={"platform": SENSOR_DOMAIN}, + ) + if discovery_info: + sensor_config = discovery_info - await async_setup_reload_service(hass, DOMAIN, PLATFORMS) - - name: str = config[CONF_NAME] - command: str = config[CONF_COMMAND] - unit: str | None = config.get(CONF_UNIT_OF_MEASUREMENT) - value_template: Template | None = config.get(CONF_VALUE_TEMPLATE) - command_timeout: int = config[CONF_COMMAND_TIMEOUT] - unique_id: str | None = config.get(CONF_UNIQUE_ID) + name: str = sensor_config[CONF_NAME] + command: str = sensor_config[CONF_COMMAND] + unit: str | None = sensor_config.get(CONF_UNIT_OF_MEASUREMENT) + value_template: Template | None = sensor_config.get(CONF_VALUE_TEMPLATE) + command_timeout: int = sensor_config[CONF_COMMAND_TIMEOUT] + unique_id: str | None = sensor_config.get(CONF_UNIQUE_ID) if value_template is not None: value_template.hass = hass - json_attributes: list[str] | None = config.get(CONF_JSON_ATTRIBUTES) + json_attributes: list[str] | None = sensor_config.get(CONF_JSON_ATTRIBUTES) data = CommandSensorData(hass, command, command_timeout) async_add_entities( diff --git a/homeassistant/components/command_line/strings.json b/homeassistant/components/command_line/strings.json new file mode 100644 index 000000000000..5c1e7d116309 --- /dev/null +++ b/homeassistant/components/command_line/strings.json @@ -0,0 +1,8 @@ +{ + "issues": { + "deprecated_platform_yaml": { + "title": "Command Line YAML configuration has been deprecated", + "description": "Configuring Command Line `{platform}` using YAML has been deprecated.\n\nConsult the documentation to move your YAML configuration to integration key and restart Home Assistant to resolve this issue." + } + } +} diff --git a/homeassistant/components/command_line/switch.py b/homeassistant/components/command_line/switch.py index bfb45f5b5c42..7936bacd432c 100644 --- a/homeassistant/components/command_line/switch.py +++ b/homeassistant/components/command_line/switch.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Any import voluptuous as vol from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, ENTITY_ID_FORMAT, PLATFORM_SCHEMA, SwitchEntity, @@ -26,12 +27,13 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.reload import async_setup_reload_service +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.template import Template from homeassistant.helpers.template_entity import ManualTriggerEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.util import slugify -from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, PLATFORMS +from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN from .utils import call_shell_with_timeout, check_output_or_log _LOGGER = logging.getLogger(__name__) @@ -62,16 +64,38 @@ async def async_setup_platform( ) -> None: """Find and return switches controlled by shell commands.""" - await async_setup_reload_service(hass, DOMAIN, PLATFORMS) + if discovery_info: + entities: dict[str, Any] = {slugify(discovery_info[CONF_NAME]): discovery_info} + else: + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml_switch", + breaks_in_ha_version="2023.8.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_platform_yaml", + translation_placeholders={"platform": SWITCH_DOMAIN}, + ) + entities = config.get(CONF_SWITCHES, {}) - devices: dict[str, Any] = config.get(CONF_SWITCHES, {}) switches = [] - for object_id, device_config in devices.items(): + for object_id, device_config in entities.items(): + if name := device_config.get( + CONF_FRIENDLY_NAME + ): # Backward compatibility. Can be removed after deprecation + device_config[CONF_NAME] = name + + if icon := device_config.get( + CONF_ICON_TEMPLATE + ): # Backward compatibility. Can be removed after deprecation + device_config[CONF_ICON] = icon + trigger_entity_config = { CONF_UNIQUE_ID: device_config.get(CONF_UNIQUE_ID), - CONF_NAME: Template(device_config.get(CONF_FRIENDLY_NAME, object_id), hass), - CONF_ICON: device_config.get(CONF_ICON_TEMPLATE), + CONF_NAME: Template(device_config.get(CONF_NAME, object_id), hass), + CONF_ICON: device_config.get(CONF_ICON), } value_template: Template | None = device_config.get(CONF_VALUE_TEMPLATE) diff --git a/tests/components/command_line/conftest.py b/tests/components/command_line/conftest.py new file mode 100644 index 000000000000..5dc4152f8f23 --- /dev/null +++ b/tests/components/command_line/conftest.py @@ -0,0 +1,72 @@ +"""Fixtures for command_line.""" + +from typing import Any + +import pytest + +from homeassistant import setup +from homeassistant.components.command_line.const import DOMAIN +from homeassistant.core import HomeAssistant + + +@pytest.fixture(name="get_config") +async def get_config_to_integration_load() -> dict[str, Any]: + """Return default minimal configuration. + + To override the config, tests can be marked with: + @pytest.mark.parametrize("get_config", [{...}]) + """ + + return { + "command_line": [ + { + "binary_sensor": { + "name": "Test", + "command": "echo 1", + "payload_on": "1", + "payload_off": "0", + "command_timeout": 15, + } + }, + { + "cover": { + "name": "Test", + "command_state": "echo 1", + "command_timeout": 15, + } + }, + { + "notify": { + "name": "Test", + "command": "echo 1", + "command_timeout": 15, + } + }, + { + "sensor": { + "name": "Test", + "command": "echo 5", + "unit_of_measurement": "in", + "command_timeout": 15, + } + }, + { + "switch": { + "name": "Test", + "command_state": "echo 1", + "command_timeout": 15, + } + }, + ] + } + + +@pytest.fixture(name="load_yaml_integration") +async def load_int(hass: HomeAssistant, get_config: dict[str, Any]) -> None: + """Set up the Command Line integration in Home Assistant.""" + await setup.async_setup_component( + hass, + DOMAIN, + get_config, + ) + await hass.async_block_till_done() diff --git a/tests/components/command_line/test_binary_sensor.py b/tests/components/command_line/test_binary_sensor.py index a6486b40040e..6f79b6bdacfe 100644 --- a/tests/components/command_line/test_binary_sensor.py +++ b/tests/components/command_line/test_binary_sensor.py @@ -6,32 +6,63 @@ from typing import Any import pytest from homeassistant import setup -from homeassistant.components.binary_sensor import DOMAIN +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.command_line.const import DOMAIN from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +import homeassistant.helpers.issue_registry as ir -async def setup_test_entity(hass: HomeAssistant, config_dict: dict[str, Any]) -> None: - """Set up a test command line binary_sensor entity.""" +async def test_setup_platform_yaml(hass: HomeAssistant) -> None: + """Test sensor setup.""" assert await setup.async_setup_component( hass, - DOMAIN, - {DOMAIN: {"platform": "command_line", "name": "Test", **config_dict}}, + BINARY_SENSOR_DOMAIN, + { + BINARY_SENSOR_DOMAIN: { + "platform": "command_line", + "name": "Test", + "command": "echo 1", + "payload_on": "1", + "payload_off": "0", + } + }, ) await hass.async_block_till_done() + entity_state = hass.states.get("binary_sensor.test") + assert entity_state + assert entity_state.state == STATE_ON + assert entity_state.name == "Test" -async def test_setup(hass: HomeAssistant) -> None: - """Test sensor setup.""" - await setup_test_entity( - hass, + issue_registry = ir.async_get(hass) + issue = issue_registry.async_get_issue(DOMAIN, "deprecated_yaml_binary_sensor") + assert issue.translation_key == "deprecated_platform_yaml" + + +@pytest.mark.parametrize( + "get_config", + [ { - "command": "echo 1", - "payload_on": "1", - "payload_off": "0", - }, - ) + "command_line": [ + { + "binary_sensor": { + "name": "Test", + "command": "echo 1", + "payload_on": "1", + "payload_off": "0", + "command_timeout": 15, + } + } + ] + } + ], +) +async def test_setup_integration_yaml( + hass: HomeAssistant, load_yaml_integration: None +) -> None: + """Test sensor setup.""" entity_state = hass.states.get("binary_sensor.test") assert entity_state @@ -39,67 +70,88 @@ async def test_setup(hass: HomeAssistant) -> None: assert entity_state.name == "Test" -async def test_template(hass: HomeAssistant) -> None: - """Test setting the state with a template.""" - - await setup_test_entity( - hass, +@pytest.mark.parametrize( + "get_config", + [ { - "command": "echo 10", - "payload_on": "1.0", - "payload_off": "0", - "value_template": "{{ value | multiply(0.1) }}", - }, - ) + "command_line": [ + { + "binary_sensor": { + "name": "Test", + "command": "echo 10", + "payload_on": "1.0", + "payload_off": "0", + "value_template": "{{ value | multiply(0.1) }}", + } + } + ] + } + ], +) +async def test_template(hass: HomeAssistant, load_yaml_integration: None) -> None: + """Test setting the state with a template.""" entity_state = hass.states.get("binary_sensor.test") assert entity_state assert entity_state.state == STATE_ON -async def test_sensor_off(hass: HomeAssistant) -> None: - """Test setting the state with a template.""" - await setup_test_entity( - hass, +@pytest.mark.parametrize( + "get_config", + [ { - "command": "echo 0", - "payload_on": "1", - "payload_off": "0", - }, - ) + "command_line": [ + { + "binary_sensor": { + "name": "Test", + "command": "echo 0", + "payload_on": "1", + "payload_off": "0", + } + } + ] + } + ], +) +async def test_sensor_off(hass: HomeAssistant, load_yaml_integration: None) -> None: + """Test setting the state with a template.""" + entity_state = hass.states.get("binary_sensor.test") assert entity_state assert entity_state.state == STATE_OFF -async def test_unique_id( - hass: HomeAssistant, entity_registry: er.EntityRegistry -) -> None: - """Test unique_id option and if it only creates one binary sensor per id.""" - assert await setup.async_setup_component( - hass, - DOMAIN, +@pytest.mark.parametrize( + "get_config", + [ { - DOMAIN: [ + "command_line": [ { - "platform": "command_line", - "unique_id": "unique", - "command": "echo 0", + "binary_sensor": { + "unique_id": "unique", + "command": "echo 0", + } }, { - "platform": "command_line", - "unique_id": "not-so-unique-anymore", - "command": "echo 1", + "binary_sensor": { + "unique_id": "not-so-unique-anymore", + "command": "echo 1", + } }, { - "platform": "command_line", - "unique_id": "not-so-unique-anymore", - "command": "echo 2", + "binary_sensor": { + "unique_id": "not-so-unique-anymore", + "command": "echo 2", + } }, ] - }, - ) - await hass.async_block_till_done() + } + ], +) +async def test_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry, load_yaml_integration: None +) -> None: + """Test unique_id option and if it only creates one binary sensor per id.""" assert len(hass.states.async_all()) == 2 @@ -112,14 +164,28 @@ async def test_unique_id( ) +@pytest.mark.parametrize( + "get_config", + [ + { + "command_line": [ + { + "binary_sensor": { + "command": "exit 33", + } + } + ] + } + ], +) async def test_return_code( - caplog: pytest.LogCaptureFixture, hass: HomeAssistant + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, get_config: dict[str, Any] ) -> None: """Test setting the state with a template.""" - await setup_test_entity( + await setup.async_setup_component( hass, - { - "command": "exit 33", - }, + DOMAIN, + get_config, ) + await hass.async_block_till_done() assert "return code 33" in caplog.text diff --git a/tests/components/command_line/test_cover.py b/tests/components/command_line/test_cover.py index a650bd6c4fbf..057e632c325f 100644 --- a/tests/components/command_line/test_cover.py +++ b/tests/components/command_line/test_cover.py @@ -3,13 +3,13 @@ from __future__ import annotations import os import tempfile -from typing import Any from unittest.mock import patch import pytest from homeassistant import config as hass_config, setup -from homeassistant.components.cover import DOMAIN, SCAN_INTERVAL +from homeassistant.components.command_line import DOMAIN +from homeassistant.components.cover import DOMAIN as COVER_DOMAIN, SCAN_INTERVAL from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_CLOSE_COVER, @@ -19,35 +19,79 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +import homeassistant.helpers.issue_registry as ir import homeassistant.util.dt as dt_util from tests.common import async_fire_time_changed, get_fixture_path -async def setup_test_entity(hass: HomeAssistant, config_dict: dict[str, Any]) -> None: - """Set up a test command line notify service.""" - assert await setup.async_setup_component( - hass, - DOMAIN, - { - DOMAIN: [ - {"platform": "command_line", "covers": config_dict}, - ] - }, - ) - await hass.async_block_till_done() - - -async def test_no_covers(caplog: pytest.LogCaptureFixture, hass: HomeAssistant) -> None: +async def test_no_covers_platform_yaml( + caplog: pytest.LogCaptureFixture, hass: HomeAssistant +) -> None: """Test that the cover does not polls when there's no state command.""" with patch( "homeassistant.components.command_line.utils.subprocess.check_output", return_value=b"50\n", ): - await setup_test_entity(hass, {}) + assert await setup.async_setup_component( + hass, + COVER_DOMAIN, + { + COVER_DOMAIN: [ + {"platform": "command_line", "covers": {}}, + ] + }, + ) + await hass.async_block_till_done() assert "No covers added" in caplog.text + issue_registry = ir.async_get(hass) + issue = issue_registry.async_get_issue(DOMAIN, "deprecated_yaml_cover") + assert issue.translation_key == "deprecated_platform_yaml" + + +async def test_state_value_platform_yaml(hass: HomeAssistant) -> None: + """Test with state value.""" + with tempfile.TemporaryDirectory() as tempdirname: + path = os.path.join(tempdirname, "cover_status") + assert await setup.async_setup_component( + hass, + COVER_DOMAIN, + { + COVER_DOMAIN: [ + { + "platform": "command_line", + "covers": { + "test": { + "command_state": f"cat {path}", + "command_open": f"echo 1 > {path}", + "command_close": f"echo 1 > {path}", + "command_stop": f"echo 0 > {path}", + "value_template": "{{ value }}", + "friendly_name": "Test", + }, + }, + }, + ] + }, + ) + await hass.async_block_till_done() + + entity_state = hass.states.get("cover.test") + assert entity_state + assert entity_state.state == "unknown" + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: "cover.test"}, + blocking=True, + ) + entity_state = hass.states.get("cover.test") + assert entity_state + assert entity_state.state == "open" + async def test_no_poll_when_cover_has_no_command_state(hass: HomeAssistant) -> None: """Test that the cover does not polls when there's no state command.""" @@ -56,20 +100,44 @@ async def test_no_poll_when_cover_has_no_command_state(hass: HomeAssistant) -> N "homeassistant.components.command_line.utils.subprocess.check_output", return_value=b"50\n", ) as check_output: - await setup_test_entity(hass, {"test": {}}) + assert await setup.async_setup_component( + hass, + COVER_DOMAIN, + { + COVER_DOMAIN: [ + {"platform": "command_line", "covers": {"test": {}}}, + ] + }, + ) async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) await hass.async_block_till_done() assert not check_output.called -async def test_poll_when_cover_has_command_state(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + "get_config", + [ + { + "command_line": [ + { + "cover": { + "command_state": "echo state", + "name": "Test", + }, + } + ] + } + ], +) +async def test_poll_when_cover_has_command_state( + hass: HomeAssistant, load_yaml_integration: None +) -> None: """Test that the cover polls when there's a state command.""" with patch( "homeassistant.components.command_line.utils.subprocess.check_output", return_value=b"50\n", ) as check_output: - await setup_test_entity(hass, {"test": {"command_state": "echo state"}}) async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) await hass.async_block_till_done() check_output.assert_called_once_with( @@ -84,57 +152,80 @@ async def test_state_value(hass: HomeAssistant) -> None: """Test with state value.""" with tempfile.TemporaryDirectory() as tempdirname: path = os.path.join(tempdirname, "cover_status") - await setup_test_entity( + await setup.async_setup_component( hass, + DOMAIN, { - "test": { - "command_state": f"cat {path}", - "command_open": f"echo 1 > {path}", - "command_close": f"echo 1 > {path}", - "command_stop": f"echo 0 > {path}", - "value_template": "{{ value }}", - } + "command_line": [ + { + "cover": { + "command_state": f"cat {path}", + "command_open": f"echo 1 > {path}", + "command_close": f"echo 1 > {path}", + "command_stop": f"echo 0 > {path}", + "value_template": "{{ value }}", + "name": "Test", + } + } + ] }, ) + await hass.async_block_till_done() entity_state = hass.states.get("cover.test") assert entity_state assert entity_state.state == "unknown" await hass.services.async_call( - DOMAIN, SERVICE_OPEN_COVER, {ATTR_ENTITY_ID: "cover.test"}, blocking=True + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: "cover.test"}, + blocking=True, ) entity_state = hass.states.get("cover.test") assert entity_state assert entity_state.state == "open" await hass.services.async_call( - DOMAIN, SERVICE_CLOSE_COVER, {ATTR_ENTITY_ID: "cover.test"}, blocking=True + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: "cover.test"}, + blocking=True, ) entity_state = hass.states.get("cover.test") assert entity_state assert entity_state.state == "open" await hass.services.async_call( - DOMAIN, SERVICE_STOP_COVER, {ATTR_ENTITY_ID: "cover.test"}, blocking=True + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: "cover.test"}, + blocking=True, ) entity_state = hass.states.get("cover.test") assert entity_state assert entity_state.state == "closed" -async def test_reload(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + "get_config", + [ + { + "command_line": [ + { + "cover": { + "command_state": "echo open", + "value_template": "{{ value }}", + "name": "Test", + } + } + ] + } + ], +) +async def test_reload(hass: HomeAssistant, load_yaml_integration: None) -> None: """Verify we can reload command_line covers.""" - await setup_test_entity( - hass, - { - "test": { - "command_state": "echo open", - "value_template": "{{ value }}", - } - }, - ) entity_state = hass.states.get("cover.test") assert entity_state assert entity_state.state == "unknown" @@ -155,50 +246,73 @@ async def test_reload(hass: HomeAssistant) -> None: assert hass.states.get("cover.from_yaml") +@pytest.mark.parametrize( + "get_config", + [ + { + "command_line": [ + { + "cover": { + "command_open": "exit 1", + "name": "Test", + } + } + ] + } + ], +) async def test_move_cover_failure( - caplog: pytest.LogCaptureFixture, hass: HomeAssistant + caplog: pytest.LogCaptureFixture, hass: HomeAssistant, load_yaml_integration: None ) -> None: """Test command failure.""" - await setup_test_entity( - hass, - {"test": {"command_open": "exit 1"}}, - ) await hass.services.async_call( - DOMAIN, SERVICE_OPEN_COVER, {ATTR_ENTITY_ID: "cover.test"}, blocking=True + COVER_DOMAIN, SERVICE_OPEN_COVER, {ATTR_ENTITY_ID: "cover.test"}, blocking=True ) assert "Command failed" in caplog.text assert "return code 1" in caplog.text +@pytest.mark.parametrize( + "get_config", + [ + { + "command_line": [ + { + "cover": { + "command_open": "echo open", + "command_close": "echo close", + "command_stop": "echo stop", + "unique_id": "unique", + "name": "Test", + } + }, + { + "cover": { + "command_open": "echo open", + "command_close": "echo close", + "command_stop": "echo stop", + "unique_id": "not-so-unique-anymore", + "name": "Test2", + } + }, + { + "cover": { + "command_open": "echo open", + "command_close": "echo close", + "command_stop": "echo stop", + "unique_id": "not-so-unique-anymore", + "name": "Test3", + } + }, + ] + } + ], +) async def test_unique_id( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, entity_registry: er.EntityRegistry, load_yaml_integration: None ) -> None: """Test unique_id option and if it only creates one cover per id.""" - await setup_test_entity( - hass, - { - "unique": { - "command_open": "echo open", - "command_close": "echo close", - "command_stop": "echo stop", - "unique_id": "unique", - }, - "not_unique_1": { - "command_open": "echo open", - "command_close": "echo close", - "command_stop": "echo stop", - "unique_id": "not-so-unique-anymore", - }, - "not_unique_2": { - "command_open": "echo open", - "command_close": "echo close", - "command_stop": "echo stop", - "unique_id": "not-so-unique-anymore", - }, - }, - ) - assert len(hass.states.async_all()) == 2 assert len(entity_registry.entities) == 2 diff --git a/tests/components/command_line/test_init.py b/tests/components/command_line/test_init.py new file mode 100644 index 000000000000..06d7b8c41dc2 --- /dev/null +++ b/tests/components/command_line/test_init.py @@ -0,0 +1,27 @@ +"""Test Command line component setup process.""" +from __future__ import annotations + +from datetime import timedelta + +from homeassistant.const import STATE_ON, STATE_OPEN +from homeassistant.core import HomeAssistant +import homeassistant.util.dt as dt_util + +from tests.common import async_fire_time_changed + + +async def test_setup_config(hass: HomeAssistant, load_yaml_integration: None) -> None: + """Test setup from yaml.""" + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10)) + await hass.async_block_till_done() + + state_binary_sensor = hass.states.get("binary_sensor.test") + state_sensor = hass.states.get("sensor.test") + state_cover = hass.states.get("cover.test") + state_switch = hass.states.get("switch.test") + + assert state_binary_sensor.state == STATE_ON + assert state_sensor.state == "5" + assert state_cover.state == STATE_OPEN + assert state_switch.state == STATE_ON diff --git a/tests/components/command_line/test_notify.py b/tests/components/command_line/test_notify.py index 918880a98fd4..aeda981ce262 100644 --- a/tests/components/command_line/test_notify.py +++ b/tests/components/command_line/test_notify.py @@ -4,40 +4,71 @@ from __future__ import annotations import os import subprocess import tempfile -from typing import Any from unittest.mock import patch import pytest from homeassistant import setup -from homeassistant.components.notify import DOMAIN +from homeassistant.components.command_line import DOMAIN +from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN from homeassistant.core import HomeAssistant +import homeassistant.helpers.issue_registry as ir -async def setup_test_service(hass: HomeAssistant, config_dict: dict[str, Any]) -> None: - """Set up a test command line notify service.""" +async def test_setup_platform_yaml(hass: HomeAssistant) -> None: + """Test sensor setup.""" assert await setup.async_setup_component( hass, - DOMAIN, + NOTIFY_DOMAIN, { - DOMAIN: [ - {"platform": "command_line", "name": "Test", **config_dict}, + NOTIFY_DOMAIN: [ + {"platform": "command_line", "name": "Test1", "command": "exit 0"}, ] }, ) await hass.async_block_till_done() + assert hass.services.has_service(NOTIFY_DOMAIN, "test1") + + issue_registry = ir.async_get(hass) + issue = issue_registry.async_get_issue(DOMAIN, "deprecated_yaml_notify") + assert issue.translation_key == "deprecated_platform_yaml" -async def test_setup(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + "get_config", + [ + { + "command_line": [ + { + "notify": { + "command": "exit 0", + "name": "Test2", + } + } + ] + } + ], +) +async def test_setup_integration_yaml( + hass: HomeAssistant, load_yaml_integration: None +) -> None: """Test sensor setup.""" - await setup_test_service(hass, {"command": "exit 0"}) - assert hass.services.has_service(DOMAIN, "test") + assert hass.services.has_service(NOTIFY_DOMAIN, "test2") async def test_bad_config(hass: HomeAssistant) -> None: """Test set up the platform with bad/missing configuration.""" - await setup_test_service(hass, {}) - assert not hass.services.has_service(DOMAIN, "test") + assert await setup.async_setup_component( + hass, + NOTIFY_DOMAIN, + { + NOTIFY_DOMAIN: [ + {"platform": "command_line"}, + ] + }, + ) + await hass.async_block_till_done() + assert not hass.services.has_service(NOTIFY_DOMAIN, "test") async def test_command_line_output(hass: HomeAssistant) -> None: @@ -45,58 +76,102 @@ async def test_command_line_output(hass: HomeAssistant) -> None: with tempfile.TemporaryDirectory() as tempdirname: filename = os.path.join(tempdirname, "message.txt") message = "one, two, testing, testing" - await setup_test_service( + await setup.async_setup_component( hass, + DOMAIN, { - "command": f"cat > {filename}", + "command_line": [ + { + "notify": { + "command": f"cat > {filename}", + "name": "Test3", + } + } + ] }, ) + await hass.async_block_till_done() - assert hass.services.has_service(DOMAIN, "test") + assert hass.services.has_service(NOTIFY_DOMAIN, "test3") assert await hass.services.async_call( - DOMAIN, "test", {"message": message}, blocking=True + NOTIFY_DOMAIN, "test3", {"message": message}, blocking=True ) - with open(filename) as handle: + with open(filename, encoding="UTF-8") as handle: # the echo command adds a line break assert message == handle.read() +@pytest.mark.parametrize( + "get_config", + [ + { + "command_line": [ + { + "notify": { + "command": "exit 1", + "name": "Test4", + } + } + ] + } + ], +) async def test_error_for_none_zero_exit_code( - caplog: pytest.LogCaptureFixture, hass: HomeAssistant + caplog: pytest.LogCaptureFixture, hass: HomeAssistant, load_yaml_integration: None ) -> None: """Test if an error is logged for non zero exit codes.""" - await setup_test_service( - hass, - { - "command": "exit 1", - }, - ) assert await hass.services.async_call( - DOMAIN, "test", {"message": "error"}, blocking=True + NOTIFY_DOMAIN, "test4", {"message": "error"}, blocking=True ) assert "Command failed" in caplog.text assert "return code 1" in caplog.text -async def test_timeout(caplog: pytest.LogCaptureFixture, hass: HomeAssistant) -> None: - """Test blocking is not forever.""" - await setup_test_service( - hass, +@pytest.mark.parametrize( + "get_config", + [ { - "command": "sleep 10000", - "command_timeout": 0.0000001, - }, - ) + "command_line": [ + { + "notify": { + "command": "sleep 10000", + "command_timeout": 0.0000001, + "name": "Test5", + } + } + ] + } + ], +) +async def test_timeout( + caplog: pytest.LogCaptureFixture, hass: HomeAssistant, load_yaml_integration: None +) -> None: + """Test blocking is not forever.""" assert await hass.services.async_call( - DOMAIN, "test", {"message": "error"}, blocking=True + NOTIFY_DOMAIN, "test5", {"message": "error"}, blocking=True ) assert "Timeout" in caplog.text +@pytest.mark.parametrize( + "get_config", + [ + { + "command_line": [ + { + "notify": { + "command": "exit 0", + "name": "Test6", + } + } + ] + } + ], +) async def test_subprocess_exceptions( - caplog: pytest.LogCaptureFixture, hass: HomeAssistant + caplog: pytest.LogCaptureFixture, hass: HomeAssistant, load_yaml_integration: None ) -> None: """Test that notify subprocess exceptions are handled correctly.""" @@ -110,15 +185,14 @@ async def test_subprocess_exceptions( subprocess.SubprocessError(), ] - await setup_test_service(hass, {"command": "exit 0"}) assert await hass.services.async_call( - DOMAIN, "test", {"message": "error"}, blocking=True + NOTIFY_DOMAIN, "test6", {"message": "error"}, blocking=True ) assert check_output.call_count == 2 assert "Timeout for command" in caplog.text assert await hass.services.async_call( - DOMAIN, "test", {"message": "error"}, blocking=True + NOTIFY_DOMAIN, "test6", {"message": "error"}, blocking=True ) assert check_output.call_count == 4 assert "Error trying to exec command" in caplog.text diff --git a/tests/components/command_line/test_sensor.py b/tests/components/command_line/test_sensor.py index 188f4aac062d..c888f1894a04 100644 --- a/tests/components/command_line/test_sensor.py +++ b/tests/components/command_line/test_sensor.py @@ -8,38 +8,65 @@ from unittest.mock import patch import pytest from homeassistant import setup +from homeassistant.components.command_line import DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +import homeassistant.helpers.issue_registry as ir from homeassistant.util import dt from tests.common import async_fire_time_changed -async def setup_test_entities(hass: HomeAssistant, config_dict: dict[str, Any]) -> None: - """Set up a test command line sensor entity.""" - hass.states.async_set("sensor.input_sensor", "sensor_value") +async def test_setup_platform_yaml(hass: HomeAssistant) -> None: + """Test sensor setup.""" assert await setup.async_setup_component( hass, SENSOR_DOMAIN, { SENSOR_DOMAIN: [ - {"platform": "command_line", "name": "Test", **config_dict}, + { + "platform": "command_line", + "name": "Test", + "command": "echo 5", + "unit_of_measurement": "in", + }, ] }, ) await hass.async_block_till_done() + entity_state = hass.states.get("sensor.test") + assert entity_state + assert entity_state.state == "5" + assert entity_state.name == "Test" + assert entity_state.attributes["unit_of_measurement"] == "in" + + issue_registry = ir.async_get(hass) + issue = issue_registry.async_get_issue(DOMAIN, "deprecated_yaml_sensor") + assert issue.translation_key == "deprecated_platform_yaml" -async def test_setup(hass: HomeAssistant) -> None: - """Test sensor setup.""" - await setup_test_entities( - hass, +@pytest.mark.parametrize( + "get_config", + [ { - "command": "echo 5", - "unit_of_measurement": "in", - }, - ) + "command_line": [ + { + "sensor": { + "name": "Test", + "command": "echo 5", + "unit_of_measurement": "in", + } + } + ] + } + ], +) +async def test_setup_integration_yaml( + hass: HomeAssistant, load_yaml_integration: None +) -> None: + """Test sensor setup.""" + entity_state = hass.states.get("sensor.test") assert entity_state assert entity_state.state == "5" @@ -47,30 +74,51 @@ async def test_setup(hass: HomeAssistant) -> None: assert entity_state.attributes["unit_of_measurement"] == "in" -async def test_template(hass: HomeAssistant) -> None: - """Test command sensor with template.""" - await setup_test_entities( - hass, +@pytest.mark.parametrize( + "get_config", + [ { - "command": "echo 50", - "unit_of_measurement": "in", - "value_template": "{{ value | multiply(0.1) }}", - }, - ) + "command_line": [ + { + "sensor": { + "name": "Test", + "command": "echo 50", + "unit_of_measurement": "in", + "value_template": "{{ value | multiply(0.1) }}", + } + } + ] + } + ], +) +async def test_template(hass: HomeAssistant, load_yaml_integration: None) -> None: + """Test command sensor with template.""" + entity_state = hass.states.get("sensor.test") assert entity_state assert float(entity_state.state) == 5 -async def test_template_render(hass: HomeAssistant) -> None: - """Ensure command with templates get rendered properly.""" - - await setup_test_entities( - hass, +@pytest.mark.parametrize( + "get_config", + [ { - "command": "echo {{ states.sensor.input_sensor.state }}", - }, - ) + "command_line": [ + { + "sensor": { + "name": "Test", + "command": "echo {{ states.sensor.input_sensor.state }}", + } + } + ] + } + ], +) +async def test_template_render( + hass: HomeAssistant, load_yaml_integration: None +) -> None: + """Ensure command with templates get rendered properly.""" + hass.states.async_set("sensor.input_sensor", "sensor_value") # Give time for template to load async_fire_time_changed( @@ -86,18 +134,27 @@ async def test_template_render(hass: HomeAssistant) -> None: async def test_template_render_with_quote(hass: HomeAssistant) -> None: """Ensure command with templates and quotes get rendered properly.""" + hass.states.async_set("sensor.input_sensor", "sensor_value") + await setup.async_setup_component( + hass, + DOMAIN, + { + "command_line": [ + { + "sensor": { + "name": "Test", + "command": 'echo "{{ states.sensor.input_sensor.state }}" "3 4"', + } + } + ] + }, + ) + await hass.async_block_till_done() with patch( "homeassistant.components.command_line.utils.subprocess.check_output", return_value=b"Works\n", ) as check_output: - await setup_test_entities( - hass, - { - "command": 'echo "{{ states.sensor.input_sensor.state }}" "3 4"', - }, - ) - # Give time for template to load async_fire_time_changed( hass, @@ -105,7 +162,7 @@ async def test_template_render_with_quote(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert len(check_output.mock_calls) == 2 + assert len(check_output.mock_calls) == 1 check_output.assert_called_with( 'echo "sensor_value" "3 4"', shell=True, # nosec # shell by design @@ -114,59 +171,116 @@ async def test_template_render_with_quote(hass: HomeAssistant) -> None: ) +@pytest.mark.parametrize( + "get_config", + [ + { + "command_line": [ + { + "sensor": { + "name": "Test", + "command": "echo {{ this template doesn't parse", + } + } + ] + } + ], +) async def test_bad_template_render( - caplog: pytest.LogCaptureFixture, hass: HomeAssistant + caplog: pytest.LogCaptureFixture, hass: HomeAssistant, get_config: dict[str, Any] ) -> None: """Test rendering a broken template.""" - - await setup_test_entities( + await setup.async_setup_component( hass, - { - "command": "echo {{ this template doesn't parse", - }, + DOMAIN, + get_config, ) + await hass.async_block_till_done() assert "Error rendering command template" in caplog.text -async def test_bad_command(hass: HomeAssistant) -> None: - """Test bad command.""" - await setup_test_entities( - hass, +@pytest.mark.parametrize( + "get_config", + [ { - "command": "asdfasdf", - }, + "command_line": [ + { + "sensor": { + "name": "Test", + "command": "asdfasdf", + } + } + ] + } + ], +) +async def test_bad_command(hass: HomeAssistant, get_config: dict[str, Any]) -> None: + """Test bad command.""" + await setup.async_setup_component( + hass, + DOMAIN, + get_config, ) + await hass.async_block_till_done() + entity_state = hass.states.get("sensor.test") assert entity_state assert entity_state.state == "unknown" +@pytest.mark.parametrize( + "get_config", + [ + { + "command_line": [ + { + "sensor": { + "name": "Test", + "command": "exit 33", + } + } + ] + } + ], +) async def test_return_code( - caplog: pytest.LogCaptureFixture, hass: HomeAssistant + caplog: pytest.LogCaptureFixture, hass: HomeAssistant, get_config: dict[str, Any] ) -> None: """Test that an error return code is logged.""" - await setup_test_entities( + await setup.async_setup_component( hass, - { - "command": "exit 33", - }, + DOMAIN, + get_config, ) + await hass.async_block_till_done() + assert "return code 33" in caplog.text -async def test_update_with_json_attrs(hass: HomeAssistant) -> None: - """Test attributes get extracted from a JSON result.""" - await setup_test_entities( - hass, +@pytest.mark.parametrize( + "get_config", + [ { - "command": ( - 'echo { \\"key\\": \\"some_json_value\\", \\"another_key\\": ' - '\\"another_json_value\\", \\"key_three\\": \\"value_three\\" }' - ), - "json_attributes": ["key", "another_key", "key_three"], - }, - ) + "command_line": [ + { + "sensor": { + "name": "Test", + "command": ( + 'echo { \\"key\\": \\"some_json_value\\", \\"another_key\\": ' + '\\"another_json_value\\", \\"key_three\\": \\"value_three\\" }' + ), + "json_attributes": ["key", "another_key", "key_three"], + } + } + ] + } + ], +) +async def test_update_with_json_attrs( + hass: HomeAssistant, load_yaml_integration: None +) -> None: + """Test attributes get extracted from a JSON result.""" entity_state = hass.states.get("sensor.test") assert entity_state assert entity_state.state == "unknown" @@ -175,19 +289,30 @@ async def test_update_with_json_attrs(hass: HomeAssistant) -> None: assert entity_state.attributes["key_three"] == "value_three" -async def test_update_with_json_attrs_and_value_template(hass: HomeAssistant) -> None: - """Test json_attributes can be used together with value_template.""" - await setup_test_entities( - hass, +@pytest.mark.parametrize( + "get_config", + [ { - "command": ( - 'echo { \\"key\\": \\"some_json_value\\", \\"another_key\\": ' - '\\"another_json_value\\", \\"key_three\\": \\"value_three\\" }' - ), - "json_attributes": ["key", "another_key", "key_three"], - "value_template": '{{ value_json["key"] }}', - }, - ) + "command_line": [ + { + "sensor": { + "name": "Test", + "command": ( + 'echo { \\"key\\": \\"some_json_value\\", \\"another_key\\": ' + '\\"another_json_value\\", \\"key_three\\": \\"value_three\\" }' + ), + "json_attributes": ["key", "another_key", "key_three"], + "value_template": '{{ value_json["key"] }}', + } + } + ] + } + ], +) +async def test_update_with_json_attrs_and_value_template( + hass: HomeAssistant, load_yaml_integration: None +) -> None: + """Test json_attributes can be used together with value_template.""" entity_state = hass.states.get("sensor.test") assert entity_state assert entity_state.state == "some_json_value" @@ -196,75 +321,134 @@ async def test_update_with_json_attrs_and_value_template(hass: HomeAssistant) -> assert entity_state.attributes["key_three"] == "value_three" +@pytest.mark.parametrize( + "get_config", + [ + { + "command_line": [ + { + "sensor": { + "name": "Test", + "command": "echo", + "json_attributes": ["key"], + } + } + ] + } + ], +) async def test_update_with_json_attrs_no_data( - caplog: pytest.LogCaptureFixture, hass: HomeAssistant + caplog: pytest.LogCaptureFixture, hass: HomeAssistant, get_config: dict[str, Any] ) -> None: """Test attributes when no JSON result fetched.""" - - await setup_test_entities( + await setup.async_setup_component( hass, - { - "command": "echo", - "json_attributes": ["key"], - }, + DOMAIN, + get_config, ) + await hass.async_block_till_done() + entity_state = hass.states.get("sensor.test") assert entity_state assert "key" not in entity_state.attributes assert "Empty reply found when expecting JSON data" in caplog.text +@pytest.mark.parametrize( + "get_config", + [ + { + "command_line": [ + { + "sensor": { + "name": "Test", + "command": "echo [1, 2, 3]", + "json_attributes": ["key"], + } + } + ] + } + ], +) async def test_update_with_json_attrs_not_dict( - caplog: pytest.LogCaptureFixture, hass: HomeAssistant + caplog: pytest.LogCaptureFixture, hass: HomeAssistant, get_config: dict[str, Any] ) -> None: """Test attributes when the return value not a dict.""" - - await setup_test_entities( + await setup.async_setup_component( hass, - { - "command": "echo [1, 2, 3]", - "json_attributes": ["key"], - }, + DOMAIN, + get_config, ) + await hass.async_block_till_done() + entity_state = hass.states.get("sensor.test") assert entity_state assert "key" not in entity_state.attributes assert "JSON result was not a dictionary" in caplog.text +@pytest.mark.parametrize( + "get_config", + [ + { + "command_line": [ + { + "sensor": { + "name": "Test", + "command": "echo This is text rather than JSON data.", + "json_attributes": ["key"], + } + } + ] + } + ], +) async def test_update_with_json_attrs_bad_json( - caplog: pytest.LogCaptureFixture, hass: HomeAssistant + caplog: pytest.LogCaptureFixture, hass: HomeAssistant, get_config: dict[str, Any] ) -> None: """Test attributes when the return value is invalid JSON.""" - - await setup_test_entities( + await setup.async_setup_component( hass, - { - "command": "echo This is text rather than JSON data.", - "json_attributes": ["key"], - }, + DOMAIN, + get_config, ) + await hass.async_block_till_done() + entity_state = hass.states.get("sensor.test") assert entity_state assert "key" not in entity_state.attributes assert "Unable to parse output as JSON" in caplog.text +@pytest.mark.parametrize( + "get_config", + [ + { + "command_line": [ + { + "sensor": { + "name": "Test", + "command": ( + 'echo { \\"key\\": \\"some_json_value\\", \\"another_key\\": ' + '\\"another_json_value\\", \\"key_three\\": \\"value_three\\" }' + ), + "json_attributes": [ + "key", + "another_key", + "key_three", + "missing_key", + ], + } + } + ] + } + ], +) async def test_update_with_missing_json_attrs( - caplog: pytest.LogCaptureFixture, hass: HomeAssistant + caplog: pytest.LogCaptureFixture, hass: HomeAssistant, load_yaml_integration: None ) -> None: """Test attributes when an expected key is missing.""" - await setup_test_entities( - hass, - { - "command": ( - 'echo { \\"key\\": \\"some_json_value\\", \\"another_key\\": ' - '\\"another_json_value\\", \\"key_three\\": \\"value_three\\" }' - ), - "json_attributes": ["key", "another_key", "key_three", "missing_key"], - }, - ) entity_state = hass.states.get("sensor.test") assert entity_state assert entity_state.attributes["key"] == "some_json_value" @@ -273,21 +457,30 @@ async def test_update_with_missing_json_attrs( assert "missing_key" not in entity_state.attributes +@pytest.mark.parametrize( + "get_config", + [ + { + "command_line": [ + { + "sensor": { + "name": "Test", + "command": ( + 'echo { \\"key\\": \\"some_json_value\\", \\"another_key\\": ' + '\\"another_json_value\\", \\"key_three\\": \\"value_three\\" }' + ), + "json_attributes": ["key", "another_key"], + } + } + ] + } + ], +) async def test_update_with_unnecessary_json_attrs( - caplog: pytest.LogCaptureFixture, hass: HomeAssistant + caplog: pytest.LogCaptureFixture, hass: HomeAssistant, load_yaml_integration: None ) -> None: """Test attributes when an expected key is missing.""" - await setup_test_entities( - hass, - { - "command": ( - 'echo { \\"key\\": \\"some_json_value\\", \\"another_key\\": ' - '\\"another_json_value\\", \\"key_three\\": \\"value_three\\" }' - ), - "json_attributes": ["key", "another_key"], - }, - ) entity_state = hass.states.get("sensor.test") assert entity_state assert entity_state.attributes["key"] == "some_json_value" @@ -295,34 +488,40 @@ async def test_update_with_unnecessary_json_attrs( assert "key_three" not in entity_state.attributes -async def test_unique_id( - hass: HomeAssistant, entity_registry: er.EntityRegistry -) -> None: - """Test unique_id option and if it only creates one sensor per id.""" - assert await setup.async_setup_component( - hass, - SENSOR_DOMAIN, +@pytest.mark.parametrize( + "get_config", + [ { - SENSOR_DOMAIN: [ + "command_line": [ { - "platform": "command_line", - "unique_id": "unique", - "command": "echo 0", + "sensor": { + "name": "Test", + "unique_id": "unique", + "command": "echo 0", + } }, { - "platform": "command_line", - "unique_id": "not-so-unique-anymore", - "command": "echo 1", + "sensor": { + "name": "Test", + "unique_id": "not-so-unique-anymore", + "command": "echo 1", + } }, { - "platform": "command_line", - "unique_id": "not-so-unique-anymore", - "command": "echo 2", + "sensor": { + "name": "Test", + "unique_id": "not-so-unique-anymore", + "command": "echo 2", + }, }, ] - }, - ) - await hass.async_block_till_done() + } + ], +) +async def test_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry, load_yaml_integration: None +) -> None: + """Test unique_id option and if it only creates one sensor per id.""" assert len(hass.states.async_all()) == 2 diff --git a/tests/components/command_line/test_switch.py b/tests/components/command_line/test_switch.py index 69e78379afe6..017c453aa8b6 100644 --- a/tests/components/command_line/test_switch.py +++ b/tests/components/command_line/test_switch.py @@ -5,13 +5,13 @@ import json import os import subprocess import tempfile -from typing import Any from unittest.mock import patch import pytest from homeassistant import setup -from homeassistant.components.switch import DOMAIN, SCAN_INTERVAL +from homeassistant.components.command_line import DOMAIN +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SCAN_INTERVAL from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, @@ -21,45 +21,45 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +import homeassistant.helpers.issue_registry as ir import homeassistant.util.dt as dt_util from tests.common import async_fire_time_changed -async def setup_test_entity(hass: HomeAssistant, config_dict: dict[str, Any]) -> None: - """Set up a test command line switch entity.""" - assert await setup.async_setup_component( - hass, - DOMAIN, - { - DOMAIN: [ - {"platform": "command_line", "switches": config_dict}, - ] - }, - ) - await hass.async_block_till_done() - - -async def test_state_none(hass: HomeAssistant) -> None: +async def test_state_platform_yaml(hass: HomeAssistant) -> None: """Test with none state.""" with tempfile.TemporaryDirectory() as tempdirname: path = os.path.join(tempdirname, "switch_status") - await setup_test_entity( + assert await setup.async_setup_component( hass, + SWITCH_DOMAIN, { - "test": { - "command_on": f"echo 1 > {path}", - "command_off": f"echo 0 > {path}", - } + SWITCH_DOMAIN: [ + { + "platform": "command_line", + "switches": { + "test": { + "command_on": f"echo 1 > {path}", + "command_off": f"echo 0 > {path}", + "friendly_name": "Test", + "icon_template": ( + '{% if value=="1" %} mdi:on {% else %} mdi:off {% endif %}' + ), + } + }, + }, + ] }, ) + await hass.async_block_till_done() entity_state = hass.states.get("switch.test") assert entity_state assert entity_state.state == STATE_OFF await hass.services.async_call( - DOMAIN, + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: "switch.test"}, blocking=True, @@ -70,7 +70,7 @@ async def test_state_none(hass: HomeAssistant) -> None: assert entity_state.state == STATE_ON await hass.services.async_call( - DOMAIN, + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: "switch.test"}, blocking=True, @@ -80,32 +80,69 @@ async def test_state_none(hass: HomeAssistant) -> None: assert entity_state assert entity_state.state == STATE_OFF + issue_registry = ir.async_get(hass) + issue = issue_registry.async_get_issue(DOMAIN, "deprecated_yaml_switch") + assert issue.translation_key == "deprecated_platform_yaml" + + +async def test_state_integration_yaml(hass: HomeAssistant) -> None: + """Test with none state.""" + with tempfile.TemporaryDirectory() as tempdirname: + path = os.path.join(tempdirname, "switch_status") + await setup.async_setup_component( + hass, + DOMAIN, + { + "command_line": [ + { + "switch": { + "command_on": f"echo 1 > {path}", + "command_off": f"echo 0 > {path}", + "name": "Test", + } + } + ] + }, + ) + await hass.async_block_till_done() + + entity_state = hass.states.get("switch.test") + assert entity_state + assert entity_state.state == STATE_OFF + async def test_state_value(hass: HomeAssistant) -> None: """Test with state value.""" with tempfile.TemporaryDirectory() as tempdirname: path = os.path.join(tempdirname, "switch_status") - await setup_test_entity( + await setup.async_setup_component( hass, + DOMAIN, { - "test": { - "command_state": f"cat {path}", - "command_on": f"echo 1 > {path}", - "command_off": f"echo 0 > {path}", - "value_template": '{{ value=="1" }}', - "icon_template": ( - '{% if value=="1" %} mdi:on {% else %} mdi:off {% endif %}' - ), - } + "command_line": [ + { + "switch": { + "command_state": f"cat {path}", + "command_on": f"echo 1 > {path}", + "command_off": f"echo 0 > {path}", + "value_template": '{{ value=="1" }}', + "icon": ( + '{% if value=="1" %} mdi:on {% else %} mdi:off {% endif %}' + ), + "name": "Test", + } + } + ] }, ) + await hass.async_block_till_done() entity_state = hass.states.get("switch.test") assert entity_state assert entity_state.state == STATE_OFF await hass.services.async_call( - DOMAIN, + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: "switch.test"}, blocking=True, @@ -117,7 +154,7 @@ async def test_state_value(hass: HomeAssistant) -> None: assert entity_state.attributes.get("icon") == "mdi:on" await hass.services.async_call( - DOMAIN, + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: "switch.test"}, blocking=True, @@ -136,28 +173,35 @@ async def test_state_json_value(hass: HomeAssistant) -> None: oncmd = json.dumps({"status": "ok"}) offcmd = json.dumps({"status": "nope"}) - await setup_test_entity( + await setup.async_setup_component( hass, + DOMAIN, { - "test": { - "command_state": f"cat {path}", - "command_on": f"echo '{oncmd}' > {path}", - "command_off": f"echo '{offcmd}' > {path}", - "value_template": '{{ value_json.status=="ok" }}', - "icon_template": ( - '{% if value_json.status=="ok" %} mdi:on' - "{% else %} mdi:off {% endif %}" - ), - } + "command_line": [ + { + "switch": { + "command_state": f"cat {path}", + "command_on": f"echo '{oncmd}' > {path}", + "command_off": f"echo '{offcmd}' > {path}", + "value_template": '{{ value_json.status=="ok" }}', + "icon": ( + '{% if value_json.status=="ok" %} mdi:on' + "{% else %} mdi:off {% endif %}" + ), + "name": "Test", + } + } + ] }, ) + await hass.async_block_till_done() entity_state = hass.states.get("switch.test") assert entity_state assert entity_state.state == STATE_OFF await hass.services.async_call( - DOMAIN, + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: "switch.test"}, blocking=True, @@ -169,7 +213,7 @@ async def test_state_json_value(hass: HomeAssistant) -> None: assert entity_state.attributes.get("icon") == "mdi:on" await hass.services.async_call( - DOMAIN, + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: "switch.test"}, blocking=True, @@ -185,23 +229,30 @@ async def test_state_code(hass: HomeAssistant) -> None: """Test with state code.""" with tempfile.TemporaryDirectory() as tempdirname: path = os.path.join(tempdirname, "switch_status") - await setup_test_entity( + await setup.async_setup_component( hass, + DOMAIN, { - "test": { - "command_state": f"cat {path}", - "command_on": f"echo 1 > {path}", - "command_off": f"echo 0 > {path}", - } + "command_line": [ + { + "switch": { + "command_state": f"cat {path}", + "command_on": f"echo 1 > {path}", + "command_off": f"echo 0 > {path}", + "name": "Test", + } + } + ] }, ) + await hass.async_block_till_done() entity_state = hass.states.get("switch.test") assert entity_state assert entity_state.state == STATE_OFF await hass.services.async_call( - DOMAIN, + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: "switch.test"}, blocking=True, @@ -212,7 +263,7 @@ async def test_state_code(hass: HomeAssistant) -> None: assert entity_state.state == STATE_ON await hass.services.async_call( - DOMAIN, + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: "switch.test"}, blocking=True, @@ -228,15 +279,23 @@ async def test_assumed_state_should_be_true_if_command_state_is_none( ) -> None: """Test with state value.""" - await setup_test_entity( + await setup.async_setup_component( hass, + DOMAIN, { - "test": { - "command_on": "echo 'on command'", - "command_off": "echo 'off command'", - } + "command_line": [ + { + "switch": { + "command_on": "echo 'on command'", + "command_off": "echo 'off command'", + "name": "Test", + } + } + ] }, ) + await hass.async_block_till_done() + entity_state = hass.states.get("switch.test") assert entity_state assert entity_state.attributes["assumed_state"] @@ -247,16 +306,24 @@ async def test_assumed_state_should_absent_if_command_state_present( ) -> None: """Test with state value.""" - await setup_test_entity( + await setup.async_setup_component( hass, + DOMAIN, { - "test": { - "command_on": "echo 'on command'", - "command_off": "echo 'off command'", - "command_state": "cat {}", - } + "command_line": [ + { + "switch": { + "command_on": "echo 'on command'", + "command_off": "echo 'off command'", + "command_state": "cat {}", + "name": "Test", + } + } + ] }, ) + await hass.async_block_till_done() + entity_state = hass.states.get("switch.test") assert entity_state assert "assumed_state" not in entity_state.attributes @@ -264,18 +331,24 @@ async def test_assumed_state_should_absent_if_command_state_present( async def test_name_is_set_correctly(hass: HomeAssistant) -> None: """Test that name is set correctly.""" - await setup_test_entity( + await setup.async_setup_component( hass, + DOMAIN, { - "test": { - "command_on": "echo 'on command'", - "command_off": "echo 'off command'", - "friendly_name": "Test friendly name!", - } + "command_line": [ + { + "switch": { + "command_on": "echo 'on command'", + "command_off": "echo 'off command'", + "name": "Test friendly name!", + } + } + ] }, ) + await hass.async_block_till_done() - entity_state = hass.states.get("switch.test") + entity_state = hass.states.get("switch.test_friendly_name") assert entity_state assert entity_state.name == "Test friendly name!" @@ -284,16 +357,23 @@ async def test_switch_command_state_fail( caplog: pytest.LogCaptureFixture, hass: HomeAssistant ) -> None: """Test that switch failures are handled correctly.""" - await setup_test_entity( + await setup.async_setup_component( hass, + DOMAIN, { - "test": { - "command_on": "exit 0", - "command_off": "exit 0'", - "command_state": "echo 1", - } + "command_line": [ + { + "switch": { + "command_on": "exit 0", + "command_off": "exit 0'", + "command_state": "echo 1", + "name": "Test", + } + } + ] }, ) + await hass.async_block_till_done() async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) await hass.async_block_till_done() @@ -303,7 +383,7 @@ async def test_switch_command_state_fail( assert entity_state.state == "on" await hass.services.async_call( - DOMAIN, + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: "switch.test"}, blocking=True, @@ -329,16 +409,24 @@ async def test_switch_command_state_code_exceptions( subprocess.SubprocessError(), ], ) as check_output: - await setup_test_entity( + await setup.async_setup_component( hass, + DOMAIN, { - "test": { - "command_on": "exit 0", - "command_off": "exit 0'", - "command_state": "echo 1", - } + "command_line": [ + { + "switch": { + "command_on": "exit 0", + "command_off": "exit 0'", + "command_state": "echo 1", + "name": "Test", + } + } + ] }, ) + await hass.async_block_till_done() + async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) await hass.async_block_till_done() assert check_output.called @@ -362,17 +450,25 @@ async def test_switch_command_state_value_exceptions( subprocess.SubprocessError(), ], ) as check_output: - await setup_test_entity( + await setup.async_setup_component( hass, + DOMAIN, { - "test": { - "command_on": "exit 0", - "command_off": "exit 0'", - "command_state": "echo 1", - "value_template": '{{ value=="1" }}', - } + "command_line": [ + { + "switch": { + "command_on": "exit 0", + "command_off": "exit 0'", + "command_state": "echo 1", + "value_template": '{{ value=="1" }}', + "name": "Test", + } + } + ] }, ) + await hass.async_block_till_done() + async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) await hass.async_block_till_done() assert check_output.call_count == 1 @@ -384,12 +480,24 @@ async def test_switch_command_state_value_exceptions( assert "Error trying to exec command" in caplog.text -async def test_no_switches( +async def test_no_switches_platform_yaml( caplog: pytest.LogCaptureFixture, hass: HomeAssistant ) -> None: """Test with no switches.""" - await setup_test_entity(hass, {}) + assert await setup.async_setup_component( + hass, + SWITCH_DOMAIN, + { + SWITCH_DOMAIN: [ + { + "platform": "command_line", + "switches": {}, + }, + ] + }, + ) + await hass.async_block_till_done() assert "No switches" in caplog.text @@ -397,26 +505,39 @@ async def test_unique_id( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test unique_id option and if it only creates one switch per id.""" - await setup_test_entity( + await setup.async_setup_component( hass, + DOMAIN, { - "unique": { - "command_on": "echo on", - "command_off": "echo off", - "unique_id": "unique", - }, - "not_unique_1": { - "command_on": "echo on", - "command_off": "echo off", - "unique_id": "not-so-unique-anymore", - }, - "not_unique_2": { - "command_on": "echo on", - "command_off": "echo off", - "unique_id": "not-so-unique-anymore", - }, + "command_line": [ + { + "switch": { + "command_on": "echo on", + "command_off": "echo off", + "unique_id": "unique", + "name": "Test", + } + }, + { + "switch": { + "command_on": "echo on", + "command_off": "echo off", + "unique_id": "not-so-unique-anymore", + "name": "Test2", + } + }, + { + "switch": { + "command_on": "echo on", + "command_off": "echo off", + "unique_id": "not-so-unique-anymore", + "name": "Test3", + }, + }, + ] }, ) + await hass.async_block_till_done() assert len(hass.states.async_all()) == 2 @@ -432,12 +553,24 @@ async def test_command_failure( ) -> None: """Test command failure.""" - await setup_test_entity( + await setup.async_setup_component( hass, - {"test": {"command_off": "exit 33"}}, + DOMAIN, + { + "command_line": [ + { + "switch": { + "command_off": "exit 33", + "name": "Test", + } + } + ] + }, ) + await hass.async_block_till_done() + await hass.services.async_call( - DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: "switch.test"}, blocking=True + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: "switch.test"}, blocking=True ) assert "return code 33" in caplog.text @@ -446,29 +579,39 @@ async def test_templating(hass: HomeAssistant) -> None: """Test with templating.""" with tempfile.TemporaryDirectory() as tempdirname: path = os.path.join(tempdirname, "switch_status") - await setup_test_entity( + await setup.async_setup_component( hass, + DOMAIN, { - "test": { - "command_state": f"cat {path}", - "command_on": f"echo 1 > {path}", - "command_off": f"echo 0 > {path}", - "value_template": '{{ value=="1" }}', - "icon_template": ( - '{% if this.state=="on" %} mdi:on {% else %} mdi:off {% endif %}' - ), - }, - "test2": { - "command_state": f"cat {path}", - "command_on": f"echo 1 > {path}", - "command_off": f"echo 0 > {path}", - "value_template": '{{ value=="1" }}', - "icon_template": ( - '{% if states("switch.test2")=="on" %} mdi:on {% else %} mdi:off {% endif %}' - ), - }, + "command_line": [ + { + "switch": { + "command_state": f"cat {path}", + "command_on": f"echo 1 > {path}", + "command_off": f"echo 0 > {path}", + "value_template": '{{ value=="1" }}', + "icon": ( + '{% if this.state=="on" %} mdi:on {% else %} mdi:off {% endif %}' + ), + "name": "Test", + } + }, + { + "switch": { + "command_state": f"cat {path}", + "command_on": f"echo 1 > {path}", + "command_off": f"echo 0 > {path}", + "value_template": '{{ value=="1" }}', + "icon": ( + '{% if states("switch.test2")=="on" %} mdi:on {% else %} mdi:off {% endif %}' + ), + "name": "Test2", + }, + }, + ] }, ) + await hass.async_block_till_done() entity_state = hass.states.get("switch.test") entity_state2 = hass.states.get("switch.test2") @@ -476,13 +619,13 @@ async def test_templating(hass: HomeAssistant) -> None: assert entity_state2.state == STATE_OFF await hass.services.async_call( - DOMAIN, + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: "switch.test"}, blocking=True, ) await hass.services.async_call( - DOMAIN, + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: "switch.test2"}, blocking=True, From c44a7fe358e7a4a85b3c4b102768a47784b2201b Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 29 May 2023 15:44:09 +0200 Subject: [PATCH 633/822] Do not trigger reload when unloading or reloading MQTT entry (#93588) * Do not trigger reload when unloading MQTT entry * More cleanup * cleanup async_reload_manual_mqtt_items * Add test * Improve test * Remove unuse mocks from test * Add discovery item in test --- homeassistant/components/mqtt/__init__.py | 40 +----- homeassistant/components/mqtt/models.py | 2 - tests/components/mqtt/test_init.py | 154 ++++++++++++++++++++++ 3 files changed, 155 insertions(+), 41 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index d3806044fcc5..3fb6c8d2c485 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -29,10 +29,7 @@ from homeassistant.helpers import config_validation as cv, event, template from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import async_get_platforms -from homeassistant.helpers.reload import ( - async_integration_yaml_config, - async_reload_integration_platforms, -) +from homeassistant.helpers.reload import async_integration_yaml_config from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.typing import ConfigType @@ -374,7 +371,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: conf: ConfigType, ) -> None: """Forward the config entry setup to the platforms and set up discovery.""" - reload_manual_setup: bool = False # Local import to avoid circular dependencies # pylint: disable-next=import-outside-toplevel from . import device_automation, tag @@ -399,35 +395,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) # Setup reload service after all platforms have loaded await async_setup_reload_service() - # When the entry is reloaded, also reload manual set up items to enable MQTT - if mqtt_data.reload_entry: - mqtt_data.reload_entry = False - reload_manual_setup = True - - # When the entry was disabled before, reload manual set up items to enable - # MQTT again - if mqtt_data.reload_needed: - mqtt_data.reload_needed = False - reload_manual_setup = True - - if reload_manual_setup: - await async_reload_manual_mqtt_items(hass) await async_forward_entry_setup_and_setup_discovery(entry, conf) return True -async def async_reload_manual_mqtt_items(hass: HomeAssistant) -> None: - """Reload manual configured MQTT items.""" - await hass.services.async_call( - DOMAIN, - SERVICE_RELOAD, - {}, - blocking=True, - ) - - @websocket_api.websocket_command( {vol.Required("type"): "mqtt/device/debug_info", vol.Required("device_id"): str} ) @@ -570,17 +543,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Cleanup listeners mqtt_client.cleanup() - # Trigger reload manual MQTT items at entry setup - if (mqtt_entry_status := mqtt_config_entry_enabled(hass)) is False: - # The entry is disabled reload legacy manual items when - # the entry is enabled again - mqtt_data.reload_needed = True - elif mqtt_entry_status is True: - # The entry is reloaded: - # Trigger re-fetching the yaml config at entry setup - mqtt_data.reload_entry = True - # Reload the legacy yaml platform to make entities unavailable - await async_reload_integration_platforms(hass, DOMAIN, RELOADABLE_PLATFORMS) # Cleanup entity registry hooks registry_hooks = mqtt_data.discovery_registry_hooks while registry_hooks: diff --git a/homeassistant/components/mqtt/models.py b/homeassistant/components/mqtt/models.py index eac333e2a7a7..aeae184dc89d 100644 --- a/homeassistant/components/mqtt/models.py +++ b/homeassistant/components/mqtt/models.py @@ -307,11 +307,9 @@ class MqttData: integration_unsubscribe: dict[str, CALLBACK_TYPE] = field(default_factory=dict) last_discovery: float = 0.0 reload_dispatchers: list[CALLBACK_TYPE] = field(default_factory=list) - reload_entry: bool = False reload_handlers: dict[str, Callable[[], Coroutine[Any, Any, None]]] = field( default_factory=dict ) - reload_needed: bool = False state_write_requests: EntityTopicState = field(default_factory=EntityTopicState) subscriptions_to_restore: list[Subscription] = field(default_factory=list) tags: dict[str, dict[str, MQTTTagScanner]] = field(default_factory=dict) diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index fa8d2e42dda4..adbef1464a21 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -23,6 +23,8 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP, SERVICE_RELOAD, + STATE_UNAVAILABLE, + STATE_UNKNOWN, Platform, UnitOfTemperature, ) @@ -3737,3 +3739,155 @@ async def test_link_config_entry( ) await hass.async_block_till_done() assert _check_entities() == 2 + + +@pytest.mark.parametrize( + "hass_config", + [ + { + "mqtt": { + "sensor": [ + { + "name": "test_manual1", + "unique_id": "test_manual_unique_id123", + "state_topic": "test-topic_manual1", + }, + { + "name": "test_manual3", + "unique_id": "test_manual_unique_id789", + "state_topic": "test-topic_manual3", + }, + ] + } + } + ], +) +async def test_reload_config_entry( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test manual entities reloaded and set up correctly.""" + await mqtt_mock_entry() + + # set up item through discovery + config_discovery = { + "name": "test_discovery", + "unique_id": "test_discovery_unique456", + "state_topic": "test-topic_discovery", + } + async_fire_mqtt_message( + hass, "homeassistant/sensor/bla/config", json.dumps(config_discovery) + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + assert hass.states.get("sensor.test_discovery") is not None + + entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + + def _check_entities() -> int: + entities: list[Entity] = [] + mqtt_platforms = async_get_platforms(hass, mqtt.DOMAIN) + for mqtt_platform in mqtt_platforms: + assert mqtt_platform.config_entry is entry + entities += (entity for entity in mqtt_platform.entities.values()) + + return len(entities) + + # assert on initial set up manual items + + async_fire_mqtt_message(hass, "test-topic_manual1", "manual1_intial") + async_fire_mqtt_message(hass, "test-topic_manual3", "manual3_intial") + + assert (state := hass.states.get("sensor.test_manual1")) is not None + assert state.attributes["friendly_name"] == "test_manual1" + assert state.state == "manual1_intial" + assert (state := hass.states.get("sensor.test_manual3")) is not None + assert state.attributes["friendly_name"] == "test_manual3" + assert state.state == "manual3_intial" + assert _check_entities() == 3 + + # Reload the entry with a new configuration.yaml + # Mock configuration.yaml was updated + # The first item was updated, a new item was added, an item was removed + hass_config_new = { + "mqtt": { + "sensor": [ + { + "name": "test_manual1_updated", + "unique_id": "test_manual_unique_id123", + "state_topic": "test-topic_manual1_updated", + }, + { + "name": "test_manual2_new", + "unique_id": "test_manual_unique_id456", + "state_topic": "test-topic_manual2", + }, + ] + } + } + with patch( + "homeassistant.config.load_yaml_config_file", return_value=hass_config_new + ): + assert await hass.config_entries.async_reload(entry.entry_id) + assert entry.state is ConfigEntryState.LOADED + await hass.async_block_till_done() + + assert (state := hass.states.get("sensor.test_manual1")) is not None + assert state.attributes["friendly_name"] == "test_manual1_updated" + assert state.state == STATE_UNKNOWN + assert (state := hass.states.get("sensor.test_manual2_new")) is not None + assert state.attributes["friendly_name"] == "test_manual2_new" + assert state.state is STATE_UNKNOWN + # State of test_manual3 is still loaded but is unavailable + assert (state := hass.states.get("sensor.test_manual3")) is not None + assert state.state is STATE_UNAVAILABLE + assert (state := hass.states.get("sensor.test_discovery")) is not None + assert state.state is STATE_UNAVAILABLE + # The entity is not loaded anymore + assert _check_entities() == 2 + + async_fire_mqtt_message(hass, "test-topic_manual1_updated", "manual1_update") + async_fire_mqtt_message(hass, "test-topic_manual2", "manual2_update") + async_fire_mqtt_message(hass, "test-topic_manual3", "manual3_update") + + assert (state := hass.states.get("sensor.test_manual1")) is not None + assert state.state == "manual1_update" + assert (state := hass.states.get("sensor.test_manual2_new")) is not None + assert state.state == "manual2_update" + assert (state := hass.states.get("sensor.test_manual3")) is not None + assert state.state is STATE_UNAVAILABLE + + # Reload manual configured items and assert again + with patch( + "homeassistant.config.load_yaml_config_file", return_value=hass_config_new + ): + await hass.services.async_call( + "mqtt", + SERVICE_RELOAD, + {}, + blocking=True, + ) + await hass.async_block_till_done() + + assert (state := hass.states.get("sensor.test_manual1")) is not None + assert state.attributes["friendly_name"] == "test_manual1_updated" + assert state.state == STATE_UNKNOWN + assert (state := hass.states.get("sensor.test_manual2_new")) is not None + assert state.attributes["friendly_name"] == "test_manual2_new" + assert state.state == STATE_UNKNOWN + assert (state := hass.states.get("sensor.test_manual3")) is not None + assert state.state == STATE_UNAVAILABLE + assert _check_entities() == 2 + + async_fire_mqtt_message( + hass, "test-topic_manual1_updated", "manual1_update_after_reload" + ) + async_fire_mqtt_message(hass, "test-topic_manual2", "manual2_update_after_reload") + async_fire_mqtt_message(hass, "test-topic_manual3", "manual3_update_after_reload") + + assert (state := hass.states.get("sensor.test_manual1")) is not None + assert state.state == "manual1_update_after_reload" + assert (state := hass.states.get("sensor.test_manual2_new")) is not None + assert state.state == "manual2_update_after_reload" + assert (state := hass.states.get("sensor.test_manual3")) is not None + assert state.state is STATE_UNAVAILABLE From 3db59908ca7d06ae5b72387fb8390551e1b850d9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 29 May 2023 09:43:01 -0500 Subject: [PATCH 634/822] Switch mqtt to use async_track_device_registry_updated_event (#93603) --- homeassistant/components/mqtt/mixins.py | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index 388264380915..c78fb77bab6a 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -34,10 +34,7 @@ from homeassistant.helpers import ( device_registry as dr, entity_registry as er, ) -from homeassistant.helpers.device_registry import ( - EVENT_DEVICE_REGISTRY_UPDATED, - DeviceEntry, -) +from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, @@ -49,7 +46,10 @@ from homeassistant.helpers.entity import ( async_generate_entity_id, ) from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_track_entity_registry_updated_event +from homeassistant.helpers.event import ( + async_track_device_registry_updated_event, + async_track_entity_registry_updated_event, +) from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.json import json_loads @@ -680,8 +680,8 @@ class MqttDiscoveryDeviceUpdate(ABC): ) config_entry.async_on_unload(self._entry_unload) if device_id is not None: - self._remove_device_updated = hass.bus.async_listen( - EVENT_DEVICE_REGISTRY_UPDATED, self._async_device_removed + self._remove_device_updated = async_track_device_registry_updated_event( + hass, device_id, self._async_device_removed ) _LOGGER.info( "%s %s has been initialized", @@ -1182,19 +1182,16 @@ def async_removed_from_device( hass: HomeAssistant, event: Event, mqtt_device_id: str, config_entry_id: str ) -> bool: """Check if the passed event indicates MQTT was removed from a device.""" - device_id: str = event.data["device_id"] - if event.data["action"] not in ("remove", "update"): + action: str = event.data["action"] + if action not in ("remove", "update"): return False - if device_id != mqtt_device_id: - return False - - if event.data["action"] == "update": + if action == "update": if "config_entries" not in event.data["changes"]: return False device_registry = dr.async_get(hass) if ( - device_entry := device_registry.async_get(device_id) + device_entry := device_registry.async_get(event.data["device_id"]) ) and config_entry_id in device_entry.config_entries: # Not removed from device return False From db931132a81930422bf3232e358abcb24b4282d9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 29 May 2023 10:06:13 -0500 Subject: [PATCH 635/822] Fix thread leak in onvif discovery (#93742) --- homeassistant/components/onvif/config_flow.py | 15 +++++---- tests/components/onvif/test_config_flow.py | 32 +++++++++++-------- 2 files changed, 28 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/onvif/config_flow.py b/homeassistant/components/onvif/config_flow.py index 0d0ddc180dfb..c1df94f5f838 100644 --- a/homeassistant/components/onvif/config_flow.py +++ b/homeassistant/components/onvif/config_flow.py @@ -51,12 +51,15 @@ CONF_MANUAL_INPUT = "Manually configure ONVIF device" def wsdiscovery() -> list[Service]: """Get ONVIF Profile S devices from network.""" discovery = WSDiscovery(ttl=4) - discovery.start() - services = discovery.searchServices( - scopes=[Scope("onvif://www.onvif.org/Profile/Streaming")] - ) - discovery.stop() - return services + try: + discovery.start() + return discovery.searchServices( + scopes=[Scope("onvif://www.onvif.org/Profile/Streaming")] + ) + finally: + discovery.stop() + # Stop the threads started by WSDiscovery since otherwise there is a leak. + discovery._stopThreads() # pylint: disable=protected-access async def async_discovery(hass: HomeAssistant) -> list[dict[str, Any]]: diff --git a/tests/components/onvif/test_config_flow.py b/tests/components/onvif/test_config_flow.py index adb8a33251c2..00fc77076e84 100644 --- a/tests/components/onvif/test_config_flow.py +++ b/tests/components/onvif/test_config_flow.py @@ -60,10 +60,13 @@ def setup_mock_discovery( with_mac=False, two_devices=False, with_hardware=True, + no_devices=False, ): """Prepare mock discovery result.""" services = [] for item in DISCOVERY: + if no_devices: + continue service = MagicMock() service.getXAddrs = MagicMock( return_value=[ @@ -92,7 +95,10 @@ def setup_mock_discovery( scopes.append(scope) service.getScopes = MagicMock(return_value=scopes) services.append(service) - mock_discovery.return_value = services + + mock_ws_discovery = MagicMock() + mock_ws_discovery.searchServices = MagicMock(return_value=services) + mock_discovery.return_value = mock_ws_discovery async def test_flow_discovered_devices(hass: HomeAssistant) -> None: @@ -108,7 +114,7 @@ async def test_flow_discovered_devices(hass: HomeAssistant) -> None: with patch( "homeassistant.components.onvif.config_flow.get_device" ) as mock_onvif_camera, patch( - "homeassistant.components.onvif.config_flow.wsdiscovery" + "homeassistant.components.onvif.config_flow.WSDiscovery" ) as mock_discovery, patch( "homeassistant.components.onvif.ONVIFDevice" ) as mock_device: @@ -178,7 +184,7 @@ async def test_flow_discovered_devices_ignore_configured_manual_input( with patch( "homeassistant.components.onvif.config_flow.get_device" ) as mock_onvif_camera, patch( - "homeassistant.components.onvif.config_flow.wsdiscovery" + "homeassistant.components.onvif.config_flow.WSDiscovery" ) as mock_discovery, patch( "homeassistant.components.onvif.ONVIFDevice" ) as mock_device: @@ -217,12 +223,12 @@ async def test_flow_discovered_no_device(hass: HomeAssistant) -> None: with patch( "homeassistant.components.onvif.config_flow.get_device" ) as mock_onvif_camera, patch( - "homeassistant.components.onvif.config_flow.wsdiscovery" + "homeassistant.components.onvif.config_flow.WSDiscovery" ) as mock_discovery, patch( "homeassistant.components.onvif.ONVIFDevice" ) as mock_device: setup_mock_onvif_camera(mock_onvif_camera) - mock_discovery.return_value = [] + setup_mock_discovery(mock_discovery, no_devices=True) setup_mock_device(mock_device) result = await hass.config_entries.flow.async_configure( @@ -259,7 +265,7 @@ async def test_flow_discovery_ignore_existing_and_abort(hass: HomeAssistant) -> with patch( "homeassistant.components.onvif.config_flow.get_device" ) as mock_onvif_camera, patch( - "homeassistant.components.onvif.config_flow.wsdiscovery" + "homeassistant.components.onvif.config_flow.WSDiscovery" ) as mock_discovery, patch( "homeassistant.components.onvif.ONVIFDevice" ) as mock_device: @@ -302,7 +308,7 @@ async def test_flow_manual_entry(hass: HomeAssistant) -> None: with patch( "homeassistant.components.onvif.config_flow.get_device" ) as mock_onvif_camera, patch( - "homeassistant.components.onvif.config_flow.wsdiscovery" + "homeassistant.components.onvif.config_flow.WSDiscovery" ) as mock_discovery, patch( "homeassistant.components.onvif.ONVIFDevice" ) as mock_device: @@ -359,7 +365,7 @@ async def test_flow_manual_entry_no_profiles(hass: HomeAssistant) -> None: with patch( "homeassistant.components.onvif.config_flow.get_device" ) as mock_onvif_camera, patch( - "homeassistant.components.onvif.config_flow.wsdiscovery" + "homeassistant.components.onvif.config_flow.WSDiscovery" ) as mock_discovery, patch( "homeassistant.components.onvif.ONVIFDevice" ) as mock_device: @@ -399,7 +405,7 @@ async def test_flow_manual_entry_no_mac(hass: HomeAssistant) -> None: with patch( "homeassistant.components.onvif.config_flow.get_device" ) as mock_onvif_camera, patch( - "homeassistant.components.onvif.config_flow.wsdiscovery" + "homeassistant.components.onvif.config_flow.WSDiscovery" ) as mock_discovery, patch( "homeassistant.components.onvif.ONVIFDevice" ) as mock_device: @@ -441,7 +447,7 @@ async def test_flow_manual_entry_fails(hass: HomeAssistant) -> None: with patch( "homeassistant.components.onvif.config_flow.get_device" ) as mock_onvif_camera, patch( - "homeassistant.components.onvif.config_flow.wsdiscovery" + "homeassistant.components.onvif.config_flow.WSDiscovery" ) as mock_discovery, patch( "homeassistant.components.onvif.ONVIFDevice" ) as mock_device: @@ -549,7 +555,7 @@ async def test_flow_manual_entry_wrong_password(hass: HomeAssistant) -> None: with patch( "homeassistant.components.onvif.config_flow.get_device" ) as mock_onvif_camera, patch( - "homeassistant.components.onvif.config_flow.wsdiscovery" + "homeassistant.components.onvif.config_flow.WSDiscovery" ) as mock_discovery, patch( "homeassistant.components.onvif.ONVIFDevice" ) as mock_device: @@ -826,7 +832,7 @@ async def test_flow_manual_entry_updates_existing_user_password( with patch( "homeassistant.components.onvif.config_flow.get_device" ) as mock_onvif_camera, patch( - "homeassistant.components.onvif.config_flow.wsdiscovery" + "homeassistant.components.onvif.config_flow.WSDiscovery" ) as mock_discovery, patch( "homeassistant.components.onvif.ONVIFDevice" ) as mock_device: @@ -877,7 +883,7 @@ async def test_flow_manual_entry_wrong_port(hass: HomeAssistant) -> None: with patch( "homeassistant.components.onvif.config_flow.get_device" ) as mock_onvif_camera, patch( - "homeassistant.components.onvif.config_flow.wsdiscovery" + "homeassistant.components.onvif.config_flow.WSDiscovery" ) as mock_discovery, patch( "homeassistant.components.onvif.ONVIFDevice" ) as mock_device: From b42a5bab89983aa6472da328e689b9610bdb7a5f Mon Sep 17 00:00:00 2001 From: mkmer Date: Mon, 29 May 2023 11:48:08 -0400 Subject: [PATCH 636/822] Bump Blinkpy to 0.21.0 (#93735) --- homeassistant/components/blink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/blink/manifest.json b/homeassistant/components/blink/manifest.json index 3e061df32a29..302a9f1e86a0 100644 --- a/homeassistant/components/blink/manifest.json +++ b/homeassistant/components/blink/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/blink", "iot_class": "cloud_polling", "loggers": ["blinkpy"], - "requirements": ["blinkpy==0.19.2"] + "requirements": ["blinkpy==0.21.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index f97ba7653c9b..372118195a4c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -449,7 +449,7 @@ bleak==0.20.2 blebox_uniapi==2.1.4 # homeassistant.components.blink -blinkpy==0.19.2 +blinkpy==0.21.0 # homeassistant.components.blinksticklight blinkstick==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2ee4e7ca095a..966373d8e6dd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -379,7 +379,7 @@ bleak==0.20.2 blebox_uniapi==2.1.4 # homeassistant.components.blink -blinkpy==0.19.2 +blinkpy==0.21.0 # homeassistant.components.bluemaestro bluemaestro-ble==0.2.3 From 3c5475a558145676e1a535309fcb900616cba48f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 29 May 2023 11:03:36 -0500 Subject: [PATCH 637/822] Drop codeowner for radiotherm (#93744) --- CODEOWNERS | 4 ++-- homeassistant/components/radiotherm/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 763c47840d3d..3ca8df7ec16b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -971,8 +971,8 @@ build.json @home-assistant/supervisor /tests/components/radarr/ @tkdrob /homeassistant/components/radio_browser/ @frenck /tests/components/radio_browser/ @frenck -/homeassistant/components/radiotherm/ @bdraco @vinnyfuria -/tests/components/radiotherm/ @bdraco @vinnyfuria +/homeassistant/components/radiotherm/ @vinnyfuria +/tests/components/radiotherm/ @vinnyfuria /homeassistant/components/rainbird/ @konikvranik @allenporter /tests/components/rainbird/ @konikvranik @allenporter /homeassistant/components/raincloud/ @vanstinator diff --git a/homeassistant/components/radiotherm/manifest.json b/homeassistant/components/radiotherm/manifest.json index aaa8671cd58d..185a034d7f21 100644 --- a/homeassistant/components/radiotherm/manifest.json +++ b/homeassistant/components/radiotherm/manifest.json @@ -1,7 +1,7 @@ { "domain": "radiotherm", "name": "Radio Thermostat", - "codeowners": ["@bdraco", "@vinnyfuria"], + "codeowners": ["@vinnyfuria"], "config_flow": true, "dhcp": [ { From 3b4273d018eb6557ba369910b8a2ef7a88837166 Mon Sep 17 00:00:00 2001 From: Luke Date: Mon, 29 May 2023 12:40:35 -0400 Subject: [PATCH 638/822] Add Roborock Clean time sensors (#93637) * add clean time information * add sensor test * update to cleaning time * fix sensor test --- homeassistant/components/roborock/sensor.py | 21 ++++++++++++++++++- .../components/roborock/strings.json | 6 ++++++ tests/components/roborock/test_sensor.py | 6 +++++- 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index 8f14b227a9cf..7fb608648113 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -76,6 +76,25 @@ CONSUMABLE_SENSORS = [ ), ] +CLEAN_INFORMATION_SENSORS = [ + RoborockSensorDescription( + native_unit_of_measurement=UnitOfTime.SECONDS, + key="cleaning_time", + translation_key="cleaning_time", + icon="mdi:progress-clock", + device_class=SensorDeviceClass.DURATION, + value_fn=lambda data: data.status.clean_time, + ), + RoborockSensorDescription( + native_unit_of_measurement=UnitOfTime.SECONDS, + key="total_cleaning_time", + translation_key="total_cleaning_time", + icon="mdi:history", + device_class=SensorDeviceClass.DURATION, + value_fn=lambda data: data.clean_summary.clean_time, + ), +] + async def async_setup_entry( hass: HomeAssistant, @@ -93,7 +112,7 @@ async def async_setup_entry( description, ) for device_id, coordinator in coordinators.items() - for description in CONSUMABLE_SENSORS + for description in CONSUMABLE_SENSORS + CLEAN_INFORMATION_SENSORS ) diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 7e8e2ba37a0f..226adab6cd53 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -28,6 +28,9 @@ }, "entity": { "sensor": { + "cleaning_time": { + "name": "Cleaning time" + }, "main_brush_time_left": { "name": "Main brush time left" }, @@ -39,6 +42,9 @@ }, "sensor_time_left": { "name": "Sensor time left" + }, + "total_cleaning_time": { + "name": "Total cleaning time" } }, "select": { diff --git a/tests/components/roborock/test_sensor.py b/tests/components/roborock/test_sensor.py index 844435062083..4c774cd6e32f 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")) == 4 + assert len(hass.states.async_all("sensor")) == 6 assert hass.states.get("sensor.roborock_s7_maxv_main_brush_time_left").state == str( MAIN_BRUSH_REPLACE_TIME - 74382 ) @@ -27,3 +27,7 @@ async def test_sensors(hass: HomeAssistant, setup_entry: MockConfigEntry) -> Non assert hass.states.get("sensor.roborock_s7_maxv_sensor_time_left").state == str( SENSOR_DIRTY_REPLACE_TIME - 74382 ) + assert hass.states.get("sensor.roborock_s7_maxv_cleaning_time").state == "1176" + assert ( + hass.states.get("sensor.roborock_s7_maxv_total_cleaning_time").state == "74382" + ) From 64ee1e8e437d590eb1f5a9f609abe7ae3edab7b2 Mon Sep 17 00:00:00 2001 From: Stephan Uhle Date: Mon, 29 May 2023 19:41:32 +0200 Subject: [PATCH 639/822] Bump pysml to 0.0.12 (#93686) --- homeassistant/components/edl21/manifest.json | 2 +- homeassistant/components/edl21/sensor.py | 6 +----- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/edl21/manifest.json b/homeassistant/components/edl21/manifest.json index 5fdda4634591..faa471e44b19 100644 --- a/homeassistant/components/edl21/manifest.json +++ b/homeassistant/components/edl21/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["sml"], - "requirements": ["pysml==0.0.11"] + "requirements": ["pysml==0.0.12"] } diff --git a/homeassistant/components/edl21/sensor.py b/homeassistant/components/edl21/sensor.py index c85f4a1f5b16..c2436c150572 100644 --- a/homeassistant/components/edl21/sensor.py +++ b/homeassistant/components/edl21/sensor.py @@ -322,11 +322,7 @@ class EDL21: assert isinstance(message_body, SmlGetListResponse) LOGGER.debug("Received sml message for %s: %s", self._name, message_body) - electricity_id = None - for telegram in message_body.get("valList", []): - if telegram.get("objName") in ("1-0:0.0.9*255", "1-0:96.1.0*255"): - electricity_id = telegram.get("value") - break + electricity_id = message_body["serverId"] if electricity_id is None: LOGGER.debug("No electricity id found in sml message for %s", self._name) diff --git a/requirements_all.txt b/requirements_all.txt index 372118195a4c..5f218c1a7ac4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1991,7 +1991,7 @@ pysmartapp==0.3.3 pysmartthings==0.7.6 # homeassistant.components.edl21 -pysml==0.0.11 +pysml==0.0.12 # homeassistant.components.snmp pysnmplib==5.0.21 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 966373d8e6dd..85dfcf824c61 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1471,7 +1471,7 @@ pysmartapp==0.3.3 pysmartthings==0.7.6 # homeassistant.components.edl21 -pysml==0.0.11 +pysml==0.0.12 # homeassistant.components.snmp pysnmplib==5.0.21 From e1ef027461fa07a27c549961d2a533de653a9df9 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 29 May 2023 19:45:42 +0200 Subject: [PATCH 640/822] Address late review comments for Command Line (#93743) * Late review comments * Rephrase --- homeassistant/components/command_line/__init__.py | 2 +- homeassistant/components/command_line/strings.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/command_line/__init__.py b/homeassistant/components/command_line/__init__.py index c34b35bb62f9..651094db7f19 100644 --- a/homeassistant/components/command_line/__init__.py +++ b/homeassistant/components/command_line/__init__.py @@ -142,7 +142,7 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Command Line from yaml config.""" - command_line_config: list[dict[str, dict[str, Any]]] = config.get(DOMAIN, {}) + command_line_config: list[dict[str, dict[str, Any]]] = config.get(DOMAIN, []) if not command_line_config: return True diff --git a/homeassistant/components/command_line/strings.json b/homeassistant/components/command_line/strings.json index 5c1e7d116309..dab4a77a6ec3 100644 --- a/homeassistant/components/command_line/strings.json +++ b/homeassistant/components/command_line/strings.json @@ -1,8 +1,8 @@ { "issues": { "deprecated_platform_yaml": { - "title": "Command Line YAML configuration has been deprecated", - "description": "Configuring Command Line `{platform}` using YAML has been deprecated.\n\nConsult the documentation to move your YAML configuration to integration key and restart Home Assistant to resolve this issue." + "title": "Command Line YAML configuration has moved", + "description": "Configuring Command Line `{platform}` using YAML has moved.\n\nConsult the documentation to move your YAML configuration to integration key and restart Home Assistant to fix this issue." } } } From 617ff24b9b06cc99a99c2682026d069589128c80 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Mon, 29 May 2023 19:59:11 +0200 Subject: [PATCH 641/822] Fix missed case for device tuple fallback in rfxtrx (#93575) * rfxtrx: add missed case for device tuple fallback Previously the code was prepared for a backward compatible migration of tuples, where both styles of identifiers might exist in device registry at the same time. This place was sadly missed * No need to raise special, we can assert instead * Add some basic tests --- homeassistant/components/rfxtrx/helpers.py | 8 ++++++-- tests/components/rfxtrx/test_device_action.py | 8 ++++++++ tests/components/rfxtrx/test_device_trigger.py | 9 +++++++++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/rfxtrx/helpers.py b/homeassistant/components/rfxtrx/helpers.py index 2badc6d4ca54..cfc16126359d 100644 --- a/homeassistant/components/rfxtrx/helpers.py +++ b/homeassistant/components/rfxtrx/helpers.py @@ -6,6 +6,8 @@ from RFXtrx import RFXtrxDevice, get_device from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr +from . import get_device_tuple_from_identifiers + @callback def async_get_device_object(hass: HomeAssistant, device_id: str) -> RFXtrxDevice: @@ -15,7 +17,9 @@ def async_get_device_object(hass: HomeAssistant, device_id: str) -> RFXtrxDevice if registry_device is None: raise ValueError(f"Device {device_id} not found") - device_tuple = list(list(registry_device.identifiers)[0]) + device_tuple = get_device_tuple_from_identifiers(registry_device.identifiers) + assert device_tuple + return get_device( - int(device_tuple[1], 16), int(device_tuple[2], 16), device_tuple[3] + int(device_tuple[0], 16), int(device_tuple[1], 16), device_tuple[2] ) diff --git a/tests/components/rfxtrx/test_device_action.py b/tests/components/rfxtrx/test_device_action.py index 843f5a310ad6..c4613d67282c 100644 --- a/tests/components/rfxtrx/test_device_action.py +++ b/tests/components/rfxtrx/test_device_action.py @@ -89,6 +89,14 @@ async def test_get_actions( device_entry = device_registry.async_get_device(device.device_identifiers, set()) assert device_entry + # Add alternate identifiers, to make sure we can handle future formats + identifiers: list[str] = list(*device_entry.identifiers) + device_registry.async_update_device( + device_entry.id, merge_identifiers={(identifiers[0], "_".join(identifiers[1:]))} + ) + device_entry = device_registry.async_get_device(device.device_identifiers, set()) + assert device_entry + actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id ) diff --git a/tests/components/rfxtrx/test_device_trigger.py b/tests/components/rfxtrx/test_device_trigger.py index 78d6748a1173..c33e6c94f648 100644 --- a/tests/components/rfxtrx/test_device_trigger.py +++ b/tests/components/rfxtrx/test_device_trigger.py @@ -88,6 +88,15 @@ async def test_get_triggers( await setup_entry(hass, {event.code: {}}) device_entry = device_registry.async_get_device(event.device_identifiers, set()) + assert device_entry + + # Add alternate identifiers, to make sure we can handle future formats + identifiers: list[str] = list(*event.device_identifiers) + device_registry.async_update_device( + device_entry.id, merge_identifiers={(identifiers[0], "_".join(identifiers[1:]))} + ) + device_entry = device_registry.async_get_device(event.device_identifiers, set()) + assert device_entry expected_triggers = [ { From 067fc079a3df67a75a81b5bf77334cb7403bd677 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Mon, 29 May 2023 20:20:27 +0200 Subject: [PATCH 642/822] Don't mock non async with AsyncMock (#93749) --- tests/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 7184fac81891..56014d7a5564 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -305,7 +305,7 @@ def skip_stop_scripts( return with patch( "homeassistant.helpers.script._schedule_stop_scripts_after_shutdown", - AsyncMock(), + Mock(), ): yield From a3ce1ac4979fbfbc30cba204cd76c31ae4ea8e9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Mon, 29 May 2023 20:22:42 +0200 Subject: [PATCH 643/822] Fix possible KeyError exception in Airzone Cloud (#93747) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit airzone_cloud: fix KeyError for Aidoo installations Some Airzone Cloud installations might be limited to Aidoo devices. In that case, the library will provide AZD_AIDOOS, but it won't provide AZD_ZONES, resulting in a KeyError exception. Signed-off-by: Álvaro Fernández Rojas --- homeassistant/components/airzone_cloud/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/airzone_cloud/sensor.py b/homeassistant/components/airzone_cloud/sensor.py index 9df7518fa624..0afabfcb7159 100644 --- a/homeassistant/components/airzone_cloud/sensor.py +++ b/homeassistant/components/airzone_cloud/sensor.py @@ -45,7 +45,7 @@ async def async_setup_entry( coordinator = hass.data[DOMAIN][entry.entry_id] sensors = [] - for zone_id, zone_data in coordinator.data[AZD_ZONES].items(): + for zone_id, zone_data in coordinator.data.get(AZD_ZONES, {}).items(): for description in ZONE_SENSOR_TYPES: if description.key in zone_data: sensors.append( From f5cba290ad055da894787610750ec1b713a916cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Mon, 29 May 2023 21:27:47 +0300 Subject: [PATCH 644/822] Access datetime.datetime and .time directly in caldav (#93688) --- homeassistant/components/caldav/calendar.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/caldav/calendar.py b/homeassistant/components/caldav/calendar.py index 9a01cd2186ff..edd5cc471b89 100644 --- a/homeassistant/components/caldav/calendar.py +++ b/homeassistant/components/caldav/calendar.py @@ -1,7 +1,7 @@ """Support for WebDav Calendar.""" from __future__ import annotations -from datetime import date, datetime, timedelta +from datetime import date, datetime, time, timedelta from functools import partial import logging import re @@ -321,9 +321,7 @@ class WebDavCalendarData: """Return a datetime.""" if isinstance(obj, datetime): return WebDavCalendarData.to_local(obj) - return dt.dt.datetime.combine(obj, dt.dt.time.min).replace( - tzinfo=dt.DEFAULT_TIME_ZONE - ) + return datetime.combine(obj, time.min).replace(tzinfo=dt.DEFAULT_TIME_ZONE) @staticmethod def to_local(obj: datetime | date) -> datetime | date: From 171ce747c1c89a7f49c5b3e107306b0107ae333b Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 29 May 2023 20:33:29 +0200 Subject: [PATCH 645/822] Remove nextcloud YAML configuration (#93550) --- .../components/nextcloud/__init__.py | 54 +--------------- .../components/nextcloud/config_flow.py | 23 ------- .../components/nextcloud/strings.json | 6 -- .../components/nextcloud/test_config_flow.py | 64 +------------------ 4 files changed, 4 insertions(+), 143 deletions(-) diff --git a/homeassistant/components/nextcloud/__init__.py b/homeassistant/components/nextcloud/__init__.py index 65829f713ef5..8e2f39cf9b5f 100644 --- a/homeassistant/components/nextcloud/__init__.py +++ b/homeassistant/components/nextcloud/__init__.py @@ -1,5 +1,4 @@ """The Nextcloud integration.""" -import logging from nextcloudmonitor import ( NextcloudMonitor, @@ -7,12 +6,10 @@ from nextcloudmonitor import ( NextcloudMonitorConnectionError, NextcloudMonitorRequestError, ) -import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_PASSWORD, - CONF_SCAN_INTERVAL, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL, @@ -21,58 +18,13 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType -from .const import DEFAULT_SCAN_INTERVAL, DOMAIN +from .const import DOMAIN from .coordinator import NextcloudDataUpdateCoordinator -_LOGGER = logging.getLogger(__name__) PLATFORMS = (Platform.SENSOR, Platform.BINARY_SENSOR) -# Validate user configuration -CONFIG_SCHEMA = vol.Schema( - vol.All( - cv.deprecated(DOMAIN), - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_URL): cv.url, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional( - CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL - ): cv.time_period, - }, - ) - }, - ), - extra=vol.ALLOW_EXTRA, -) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Nextcloud integration.""" - if DOMAIN in config: - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml", - breaks_in_ha_version="2023.6.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - ) - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config[DOMAIN], - ) - ) - - return True +CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/nextcloud/config_flow.py b/homeassistant/components/nextcloud/config_flow.py index c5019603c09c..ec56307aad77 100644 --- a/homeassistant/components/nextcloud/config_flow.py +++ b/homeassistant/components/nextcloud/config_flow.py @@ -2,14 +2,12 @@ from __future__ import annotations from collections.abc import Mapping -import logging from typing import Any from nextcloudmonitor import ( NextcloudMonitor, NextcloudMonitorAuthorizationError, NextcloudMonitorConnectionError, - NextcloudMonitorError, NextcloudMonitorRequestError, ) import voluptuous as vol @@ -35,8 +33,6 @@ DATA_SCHEMA_REAUTH = vol.Schema( } ) -_LOGGER = logging.getLogger(__name__) - class NextcloudConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a Nextcloud config flow.""" @@ -54,25 +50,6 @@ class NextcloudConfigFlow(ConfigFlow, domain=DOMAIN): user_input.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL), ) - async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: - """Handle a flow initiated by configuration file.""" - self._async_abort_entries_match({CONF_URL: user_input.get(CONF_URL)}) - try: - await self.hass.async_add_executor_job(self._try_connect_nc, user_input) - except NextcloudMonitorError: - _LOGGER.error( - "Connection error during import of yaml configuration, import aborted" - ) - return self.async_abort(reason="connection_error_during_import") - return await self.async_step_user( - { - CONF_URL: user_input[CONF_URL], - CONF_PASSWORD: user_input[CONF_PASSWORD], - CONF_USERNAME: user_input[CONF_USERNAME], - CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL, - } - ) - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: diff --git a/homeassistant/components/nextcloud/strings.json b/homeassistant/components/nextcloud/strings.json index e068ae4041e5..bcb530ffd734 100644 --- a/homeassistant/components/nextcloud/strings.json +++ b/homeassistant/components/nextcloud/strings.json @@ -28,11 +28,5 @@ "connection_error": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" } - }, - "issues": { - "deprecated_yaml": { - "title": "The Nextcloud YAML configuration has been deprecated", - "description": "Configuring Nextcloud using YAML has been deprecated.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the `nextcloud` YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } } } diff --git a/tests/components/nextcloud/test_config_flow.py b/tests/components/nextcloud/test_config_flow.py index ba465c5f8a72..94c037586219 100644 --- a/tests/components/nextcloud/test_config_flow.py +++ b/tests/components/nextcloud/test_config_flow.py @@ -4,14 +4,13 @@ from unittest.mock import Mock, patch from nextcloudmonitor import ( NextcloudMonitorAuthorizationError, NextcloudMonitorConnectionError, - NextcloudMonitorError, NextcloudMonitorRequestError, ) import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.nextcloud import DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_REAUTH, SOURCE_USER +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -131,67 +130,6 @@ async def test_user_already_configured( assert result["reason"] == "already_configured" -async def test_import( - hass: HomeAssistant, mock_nextcloud_monitor: Mock, snapshot: SnapshotAssertion -) -> None: - """Test that the import step works.""" - with patch( - "homeassistant.components.nextcloud.config_flow.NextcloudMonitor", - return_value=mock_nextcloud_monitor, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=VALID_CONFIG, - ) - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "nc_url" - assert result["data"] == snapshot - - -async def test_import_already_configured( - hass: HomeAssistant, mock_nextcloud_monitor: Mock -) -> None: - """Test that import step is aborted when duplicates are added.""" - entry = MockConfigEntry( - domain=DOMAIN, - title="nc_url", - unique_id="nc_url", - data=VALID_CONFIG, - ) - entry.add_to_hass(hass) - - with patch( - "homeassistant.components.nextcloud.config_flow.NextcloudMonitor", - return_value=mock_nextcloud_monitor, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=VALID_CONFIG, - ) - await hass.async_block_till_done() - - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "already_configured" - - -async def test_import_connection_error(hass: HomeAssistant) -> None: - """Test that import step is aborted on connection error.""" - with patch( - "homeassistant.components.nextcloud.config_flow.NextcloudMonitor", - side_effect=NextcloudMonitorError, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=VALID_CONFIG, - ) - await hass.async_block_till_done() - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "connection_error_during_import" - - async def test_reauth( hass: HomeAssistant, mock_nextcloud_monitor: Mock, snapshot: SnapshotAssertion ) -> None: From 8053073a77e7c26fec2c6cb37d34feee5bf4b7e0 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 29 May 2023 20:38:33 +0200 Subject: [PATCH 646/822] Add empty config schema to integrations a-c (#93608) --- homeassistant/components/api/__init__.py | 4 +++- .../components/application_credentials/__init__.py | 2 ++ homeassistant/components/assist_pipeline/__init__.py | 3 +++ homeassistant/components/auth/__init__.py | 3 +++ homeassistant/components/backup/__init__.py | 3 +++ homeassistant/components/blueprint/__init__.py | 3 +++ homeassistant/components/bluetooth/__init__.py | 8 +++++++- homeassistant/components/bluetooth_adapters/__init__.py | 3 +++ homeassistant/components/browser/__init__.py | 3 +++ homeassistant/components/color_extractor/__init__.py | 2 ++ homeassistant/components/config/__init__.py | 3 +++ homeassistant/components/configurator/__init__.py | 3 +++ 12 files changed, 38 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index 5c0a60ecef7f..6538bd345de4 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -28,7 +28,7 @@ from homeassistant.const import ( import homeassistant.core as ha from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceNotFound, TemplateError, Unauthorized -from homeassistant.helpers import template +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 from homeassistant.helpers.typing import ConfigType @@ -49,6 +49,8 @@ DOMAIN = "api" STREAM_PING_PAYLOAD = "ping" STREAM_PING_INTERVAL = 50 # seconds +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Register the API with the HTTP interface.""" diff --git a/homeassistant/components/application_credentials/__init__.py b/homeassistant/components/application_credentials/__init__.py index f1471f296665..679ff9bfac45 100644 --- a/homeassistant/components/application_credentials/__init__.py +++ b/homeassistant/components/application_credentials/__init__.py @@ -57,6 +57,8 @@ CREATE_FIELDS = { } UPDATE_FIELDS: dict = {} # Not supported +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + @dataclass class ClientCredential: diff --git a/homeassistant/components/assist_pipeline/__init__.py b/homeassistant/components/assist_pipeline/__init__.py index 7af379804e1d..055b4773be3a 100644 --- a/homeassistant/components/assist_pipeline/__init__.py +++ b/homeassistant/components/assist_pipeline/__init__.py @@ -5,6 +5,7 @@ from collections.abc import AsyncIterable from homeassistant.components import stt from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import DOMAIN @@ -35,6 +36,8 @@ __all__ = ( "PipelineEventType", ) +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Assist pipeline integration.""" diff --git a/homeassistant/components/auth/__init__.py b/homeassistant/components/auth/__init__.py index 1b47fb093936..deaf3b7892d1 100644 --- a/homeassistant/components/auth/__init__.py +++ b/homeassistant/components/auth/__init__.py @@ -149,6 +149,7 @@ from homeassistant.components.http.ban import log_invalid_auth from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.components.http.view import HomeAssistantView from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.config_entry_oauth2_flow import OAuth2AuthorizeCallbackView from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass @@ -161,6 +162,8 @@ DOMAIN = "auth" StoreResultType = Callable[[str, Credentials], str] RetrieveResultType = Callable[[str, str], Credentials | None] +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + @bind_hass def create_auth_code( diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index 8d5f9764959c..8ce8bee77936 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -1,6 +1,7 @@ """The Backup integration.""" from homeassistant.components.hassio import is_hassio from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, LOGGER @@ -8,6 +9,8 @@ from .http import async_register_http_views from .manager import BackupManager from .websocket import async_register_websocket_handlers +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Backup integration.""" diff --git a/homeassistant/components/blueprint/__init__.py b/homeassistant/components/blueprint/__init__.py index 3087309f36ae..1fe1ad8e189f 100644 --- a/homeassistant/components/blueprint/__init__.py +++ b/homeassistant/components/blueprint/__init__.py @@ -1,5 +1,6 @@ """The blueprint integration.""" from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from . import websocket_api @@ -15,6 +16,8 @@ from .errors import ( # noqa: F401 from .models import Blueprint, BlueprintInputs, DomainBlueprints # noqa: F401 from .schemas import is_blueprint_instance_config # noqa: F401 +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the blueprint integration.""" diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index 2c48b473b73d..3e35c97ca34b 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -33,7 +33,11 @@ from homeassistant.config_entries import ( from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HassJob, HomeAssistant, callback as hass_callback from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr, discovery_flow +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + discovery_flow, +) from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.event import async_call_later from homeassistant.helpers.issue_registry import ( @@ -119,6 +123,8 @@ _LOGGER = logging.getLogger(__name__) RECOMMENDED_MIN_HAOS_VERSION = AwesomeVersion("9.0.dev0") +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def _async_get_adapter_from_address( hass: HomeAssistant, address: str diff --git a/homeassistant/components/bluetooth_adapters/__init__.py b/homeassistant/components/bluetooth_adapters/__init__.py index c2af10d5455b..3d5580aabf17 100644 --- a/homeassistant/components/bluetooth_adapters/__init__.py +++ b/homeassistant/components/bluetooth_adapters/__init__.py @@ -2,10 +2,13 @@ from __future__ import annotations from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType DOMAIN = "bluetooth_adapters" +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Bluetooth Adapters from a config entry. diff --git a/homeassistant/components/browser/__init__.py b/homeassistant/components/browser/__init__.py index 954621ed66ff..b01f04fa1401 100644 --- a/homeassistant/components/browser/__init__.py +++ b/homeassistant/components/browser/__init__.py @@ -4,6 +4,7 @@ import webbrowser import voluptuous as vol from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType ATTR_URL = "url" @@ -20,6 +21,8 @@ SERVICE_BROWSE_URL_SCHEMA = vol.Schema( } ) +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + def _browser_url(service: ServiceCall) -> None: """Browse to URL.""" diff --git a/homeassistant/components/color_extractor/__init__.py b/homeassistant/components/color_extractor/__init__.py index 61ec27b32419..aa2d9cfbfdb2 100644 --- a/homeassistant/components/color_extractor/__init__.py +++ b/homeassistant/components/color_extractor/__init__.py @@ -24,6 +24,8 @@ from .const import ATTR_PATH, ATTR_URL, DOMAIN, SERVICE_TURN_ON _LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + # Extend the existing light.turn_on service schema SERVICE_SCHEMA = vol.All( cv.has_at_least_one_key(ATTR_URL, ATTR_PATH), diff --git a/homeassistant/components/config/__init__.py b/homeassistant/components/config/__init__.py index def7edd4950c..514154137e4c 100644 --- a/homeassistant/components/config/__init__.py +++ b/homeassistant/components/config/__init__.py @@ -11,6 +11,7 @@ from homeassistant.components.http import HomeAssistantView from homeassistant.const import CONF_ID, EVENT_COMPONENT_LOADED from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from homeassistant.setup import ATTR_COMPONENT from homeassistant.util.file import write_utf8_file_atomic @@ -32,6 +33,8 @@ SECTIONS = ( ACTION_CREATE_UPDATE = "create_update" ACTION_DELETE = "delete" +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the config component.""" diff --git a/homeassistant/components/configurator/__init__.py b/homeassistant/components/configurator/__init__.py index ea785b5cdfbe..6fd3917cc9c8 100644 --- a/homeassistant/components/configurator/__init__.py +++ b/homeassistant/components/configurator/__init__.py @@ -15,6 +15,7 @@ from typing import Any from homeassistant.const import ATTR_ENTITY_PICTURE, ATTR_FRIENDLY_NAME from homeassistant.core import HomeAssistant, ServiceCall, callback as async_callback +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import ConfigType @@ -44,6 +45,8 @@ STATE_CONFIGURED = "configured" ConfiguratorCallback = Callable[[list[dict[str, str]]], None] +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + @bind_hass @async_callback From 17fadbcf4a11e10747182146aa33e8d90dffd764 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 29 May 2023 13:39:34 -0500 Subject: [PATCH 647/822] Reduce overhead to update unifiprotect entity (#93725) --- .../components/unifiprotect/entity.py | 33 ++++++++++--------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/unifiprotect/entity.py b/homeassistant/components/unifiprotect/entity.py index 134b55c4b0dc..e123a4bf1bc6 100644 --- a/homeassistant/components/unifiprotect/entity.py +++ b/homeassistant/components/unifiprotect/entity.py @@ -1,7 +1,7 @@ """Shared Entity definition for UniFi Protect Integration.""" from __future__ import annotations -from collections.abc import Sequence +from collections.abc import Callable, Sequence import logging from typing import Any @@ -191,6 +191,9 @@ class ProtectDeviceEntity(Entity): super().__init__() self.data: ProtectData = data self.device = device + self._async_get_ufp_enabled: Callable[ + [ProtectAdoptableDeviceModel], bool + ] | None = None if description is None: self._attr_unique_id = f"{self.device.mac}" @@ -200,6 +203,8 @@ class ProtectDeviceEntity(Entity): self._attr_unique_id = f"{self.device.mac}_{description.key}" name = description.name or "" self._attr_name = f"{self.device.display_name} {name.title()}" + if isinstance(description, ProtectRequiredKeysMixin): + self._async_get_ufp_enabled = description.get_ufp_enabled self._attr_attribution = DEFAULT_ATTRIBUTION self._async_set_device_info() @@ -227,24 +232,20 @@ class ProtectDeviceEntity(Entity): @callback def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: """Update Entity object from Protect device.""" - if self.data.last_update_success: - assert isinstance(device, ProtectAdoptableDeviceModel) + assert isinstance(device, ProtectAdoptableDeviceModel) + + if last_update_success := self.data.last_update_success: self.device = device - is_connected = self.data.last_update_success and ( - self.device.state == StateType.CONNECTED - or (not self.device.is_adopted_by_us and self.device.can_adopt) - ) - if ( - hasattr(self, "entity_description") - and self.entity_description is not None - and hasattr(self.entity_description, "get_ufp_enabled") - ): - assert isinstance(self.entity_description, ProtectRequiredKeysMixin) - is_connected = is_connected and self.entity_description.get_ufp_enabled( - self.device + async_get_ufp_enabled = self._async_get_ufp_enabled + self._attr_available = ( + last_update_success + and ( + device.state == StateType.CONNECTED + or (not device.is_adopted_by_us and device.can_adopt) ) - self._attr_available = is_connected + and (not async_get_ufp_enabled or async_get_ufp_enabled(device)) + ) @callback def _async_updated_event(self, device: ProtectModelWithId) -> None: From a547181984b081471366a32db9f4a5ec01de28b5 Mon Sep 17 00:00:00 2001 From: Yuxin Wang Date: Mon, 29 May 2023 14:40:36 -0400 Subject: [PATCH 648/822] Fix unit inference for ITEMP field for APCUPSD integration (#93724) --- homeassistant/components/apcupsd/sensor.py | 14 ++++++++------ tests/components/apcupsd/__init__.py | 1 + 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/apcupsd/sensor.py b/homeassistant/components/apcupsd/sensor.py index 4e0e46f6392f..77d8933827db 100644 --- a/homeassistant/components/apcupsd/sensor.py +++ b/homeassistant/components/apcupsd/sensor.py @@ -3,8 +3,6 @@ from __future__ import annotations import logging -from apcaccess.status import ALL_UNITS - from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -427,7 +425,6 @@ SENSORS: dict[str, SensorEntityDescription] = { ), } -SPECIFIC_UNITS = {"ITEMP": UnitOfTemperature.CELSIUS} INFERRED_UNITS = { " Minutes": UnitOfTime.MINUTES, " Seconds": UnitOfTime.SECONDS, @@ -438,6 +435,10 @@ INFERRED_UNITS = { " Watts": UnitOfPower.WATT, " Hz": UnitOfFrequency.HERTZ, " C": UnitOfTemperature.CELSIUS, + # APCUPSd reports data for "itemp" field (eventually represented by UPS Internal + # Temperature sensor in this integration) with a trailing "Internal", e.g., + # "34.6 C Internal". Here we create a fake unit " C Internal" to handle this case. + " C Internal": UnitOfTemperature.CELSIUS, " Percent Load Capacity": PERCENTAGE, } @@ -466,15 +467,16 @@ async def async_setup_entry( def infer_unit(value: str) -> tuple[str, str | None]: - """If the value ends with any of the units from ALL_UNITS. + """If the value ends with any of the units from supported units. Split the unit off the end of the value and return the value, unit tuple pair. Else return the original value and None as the unit. """ - for unit in ALL_UNITS: + for unit, ha_unit in INFERRED_UNITS.items(): if value.endswith(unit): - return value.removesuffix(unit), INFERRED_UNITS.get(unit, unit.strip()) + return value.removesuffix(unit), ha_unit + return value, None diff --git a/tests/components/apcupsd/__init__.py b/tests/components/apcupsd/__init__.py index 64547822b428..7b58013ddbe7 100644 --- a/tests/components/apcupsd/__init__.py +++ b/tests/components/apcupsd/__init__.py @@ -26,6 +26,7 @@ MOCK_STATUS: Final = OrderedDict( ("LOADPCT", "14.0 Percent"), ("BCHARGE", "100.0 Percent"), ("TIMELEFT", "51.0 Minutes"), + ("ITEMP", "34.6 C Internal"), ("MBATTCHG", "5 Percent"), ("MINTIMEL", "3 Minutes"), ("MAXTIME", "0 Seconds"), From 9443ca89bcf615bc2ef9fdcbbb66752157250298 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 29 May 2023 13:41:50 -0500 Subject: [PATCH 649/822] Handle exceptions to prevent disconnect when esphome state subscription raises (#93723) --- homeassistant/components/esphome/entry_data.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 7ce195d68fce..225ae3961e8c 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -266,8 +266,14 @@ class RuntimeEntryData: ) stale_state.discard(subscription_key) current_state_by_type[key] = state - if subscription_key in self.state_subscriptions: - self.state_subscriptions[subscription_key]() + if subscription := self.state_subscriptions.get(subscription_key): + try: + subscription() + except Exception as ex: # pylint: disable=broad-except + # If we allow this exception to raise it will + # make it all the way to data_received in aioesphomeapi + # which will cause the connection to be closed. + _LOGGER.exception("Error while calling subscription: %s", ex) @callback def async_update_device_state(self, hass: HomeAssistant) -> None: From 7f3f2eea38911d3ead66320ff7f5736c05efae4d Mon Sep 17 00:00:00 2001 From: Jerome Garec Date: Mon, 29 May 2023 20:42:46 +0200 Subject: [PATCH 650/822] Add overkiz control for ATLANTIC_PASS_APC_HEATING_ZONE (#93712) --- homeassistant/components/overkiz/const.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/overkiz/const.py b/homeassistant/components/overkiz/const.py index 0db01a2d84c2..102d09a76b14 100644 --- a/homeassistant/components/overkiz/const.py +++ b/homeassistant/components/overkiz/const.py @@ -87,6 +87,7 @@ OVERKIZ_DEVICE_TO_PLATFORM: dict[UIClass | UIWidget, Platform | None] = { UIWidget.ATLANTIC_HEAT_RECOVERY_VENTILATION: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) UIWidget.ATLANTIC_PASS_APC_DHW: Platform.WATER_HEATER, # widgetName, uiClass is WaterHeatingSystem (not supported) UIWidget.ATLANTIC_PASS_APC_HEATING_AND_COOLING_ZONE: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) + UIWidget.ATLANTIC_PASS_APC_HEATING_ZONE: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) UIWidget.ATLANTIC_PASS_APC_ZONE_CONTROL: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) UIWidget.DOMESTIC_HOT_WATER_PRODUCTION: Platform.WATER_HEATER, # widgetName, uiClass is WaterHeatingSystem (not supported) UIWidget.DOMESTIC_HOT_WATER_TANK: Platform.SWITCH, # widgetName, uiClass is WaterHeatingSystem (not supported) From 2f1f32f0bb7c5d772f246a4fc28e60323ac8c8c8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 29 May 2023 13:50:40 -0500 Subject: [PATCH 651/822] Make unit converter use a factory to avoid looking up the ratios each conversion (#93706) --- .../components/recorder/statistics.py | 61 ++++----- homeassistant/util/unit_conversion.py | 121 +++++++++++++----- .../components/recorder/test_websocket_api.py | 30 +++++ tests/util/test_unit_conversion.py | 81 ++++++++++++ 4 files changed, 227 insertions(+), 66 deletions(-) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 70f35d0349a5..7dbe29cea7d4 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -219,50 +219,34 @@ def _get_statistic_to_display_unit_converter( if display_unit == statistic_unit: return None - convert = converter.convert - - def _from_normalized_unit(val: float | None) -> float | None: - """Return val.""" - if val is None: - return val - return convert(val, statistic_unit, display_unit) - - return _from_normalized_unit + return converter.converter_factory_allow_none( + from_unit=statistic_unit, to_unit=display_unit + ) def _get_display_to_statistic_unit_converter( display_unit: str | None, statistic_unit: str | None, -) -> Callable[[float], float]: +) -> Callable[[float], float] | None: """Prepare a converter from the display unit to the statistics unit.""" - - def no_conversion(val: float) -> float: - """Return val.""" - return val - - if (converter := STATISTIC_UNIT_TO_UNIT_CONVERTER.get(statistic_unit)) is None: - return no_conversion - - return partial(converter.convert, from_unit=display_unit, to_unit=statistic_unit) + if ( + display_unit == statistic_unit + or (converter := STATISTIC_UNIT_TO_UNIT_CONVERTER.get(statistic_unit)) is None + ): + return None + return converter.converter_factory(from_unit=display_unit, to_unit=statistic_unit) def _get_unit_converter( from_unit: str, to_unit: str -) -> Callable[[float | None], float | None]: +) -> Callable[[float | None], float | None] | None: """Prepare a converter from a unit to another unit.""" - - def convert_units( - val: float | None, conv: type[BaseUnitConverter], from_unit: str, to_unit: str - ) -> float | None: - """Return converted val.""" - if val is None: - return val - return conv.convert(val, from_unit=from_unit, to_unit=to_unit) - for conv in STATISTIC_UNIT_TO_UNIT_CONVERTER.values(): if from_unit in conv.VALID_UNITS and to_unit in conv.VALID_UNITS: - return partial( - convert_units, conv=conv, from_unit=from_unit, to_unit=to_unit + if from_unit == to_unit: + return None + return conv.converter_factory_allow_none( + from_unit=from_unit, to_unit=to_unit ) raise HomeAssistantError @@ -2290,10 +2274,10 @@ def adjust_statistics( return True statistic_unit = metadata[statistic_id][1]["unit_of_measurement"] - convert = _get_display_to_statistic_unit_converter( + if convert := _get_display_to_statistic_unit_converter( adjustment_unit, statistic_unit - ) - sum_adjustment = convert(sum_adjustment) + ): + sum_adjustment = convert(sum_adjustment) _adjust_sum_statistics( session, @@ -2360,7 +2344,14 @@ def change_statistics_unit( metadata_id = metadata[0] - convert = _get_unit_converter(old_unit, new_unit) + if not (convert := _get_unit_converter(old_unit, new_unit)): + _LOGGER.warning( + "Statistics unit of measurement for %s is already %s", + statistic_id, + new_unit, + ) + return + tables: tuple[type[StatisticsBase], ...] = ( Statistics, StatisticsShortTerm, diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index a22a204e69a6..5ce31b072cf8 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -1,6 +1,9 @@ """Typing Helpers for Home Assistant.""" from __future__ import annotations +from collections.abc import Callable +from functools import lru_cache + from homeassistant.const import ( CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, @@ -67,30 +70,49 @@ class BaseUnitConverter: @classmethod def convert(cls, value: float, from_unit: str | None, to_unit: str | None) -> float: """Convert one unit of measurement to another.""" - if from_unit == to_unit: - return value - - try: - from_ratio = cls._UNIT_CONVERSION[from_unit] - except KeyError as err: - raise HomeAssistantError( - UNIT_NOT_RECOGNIZED_TEMPLATE.format(from_unit, cls.UNIT_CLASS) - ) from err - - try: - to_ratio = cls._UNIT_CONVERSION[to_unit] - except KeyError as err: - raise HomeAssistantError( - UNIT_NOT_RECOGNIZED_TEMPLATE.format(to_unit, cls.UNIT_CLASS) - ) from err - - new_value = value / from_ratio - return new_value * to_ratio + return cls.converter_factory(from_unit, to_unit)(value) @classmethod + @lru_cache + def converter_factory( + cls, from_unit: str | None, to_unit: str | None + ) -> Callable[[float], float]: + """Return a function to convert one unit of measurement to another.""" + if from_unit == to_unit: + return lambda value: value + from_ratio, to_ratio = cls._get_from_to_ratio(from_unit, to_unit) + return lambda val: (val / from_ratio) * to_ratio + + @classmethod + def _get_from_to_ratio( + cls, from_unit: str | None, to_unit: str | None + ) -> tuple[float, float]: + """Get unit ratio between units of measurement.""" + unit_conversion = cls._UNIT_CONVERSION + try: + return unit_conversion[from_unit], unit_conversion[to_unit] + except KeyError as err: + raise HomeAssistantError( + UNIT_NOT_RECOGNIZED_TEMPLATE.format(err.args[0], cls.UNIT_CLASS) + ) from err + + @classmethod + @lru_cache + def converter_factory_allow_none( + cls, from_unit: str | None, to_unit: str | None + ) -> Callable[[float | None], float | None]: + """Return a function to convert one unit of measurement to another which allows None.""" + if from_unit == to_unit: + return lambda value: value + from_ratio, to_ratio = cls._get_from_to_ratio(from_unit, to_unit) + return lambda val: None if val is None else (val / from_ratio) * to_ratio + + @classmethod + @lru_cache def get_unit_ratio(cls, from_unit: str | None, to_unit: str | None) -> float: """Get unit ratio between units of measurement.""" - return cls._UNIT_CONVERSION[from_unit] / cls._UNIT_CONVERSION[to_unit] + from_ratio, to_ratio = cls._get_from_to_ratio(from_unit, to_unit) + return from_ratio / to_ratio class DataRateConverter(BaseUnitConverter): @@ -339,7 +361,37 @@ class TemperatureConverter(BaseUnitConverter): } @classmethod - def convert(cls, value: float, from_unit: str | None, to_unit: str | None) -> float: + @lru_cache(maxsize=8) + def converter_factory( + cls, from_unit: str | None, to_unit: str | None + ) -> Callable[[float], float]: + """Return a function to convert a temperature from one unit to another.""" + if from_unit == to_unit: + # Return a function that does nothing. This is not + # in _converter_factory because we do not want to wrap + # it with the None check in converter_factory_allow_none. + return lambda value: value + + return cls._converter_factory(from_unit, to_unit) + + @classmethod + @lru_cache(maxsize=8) + def converter_factory_allow_none( + cls, from_unit: str | None, to_unit: str | None + ) -> Callable[[float | None], float | None]: + """Return a function to convert a temperature from one unit to another which allows None.""" + if from_unit == to_unit: + # Return a function that does nothing. This is not + # in _converter_factory because we do not want to wrap + # it with the None check in this case. + return lambda value: value + convert = cls._converter_factory(from_unit, to_unit) + return lambda value: None if value is None else convert(value) + + @classmethod + def _converter_factory( + cls, from_unit: str | None, to_unit: str | None + ) -> Callable[[float], float]: """Convert a temperature from one unit to another. eg. 10°C will return 50°F @@ -349,32 +401,29 @@ class TemperatureConverter(BaseUnitConverter): """ # We cannot use the implementation from BaseUnitConverter here because the # temperature units do not use the same floor: 0°C, 0°F and 0K do not align - if from_unit == to_unit: - return value - if from_unit == UnitOfTemperature.CELSIUS: if to_unit == UnitOfTemperature.FAHRENHEIT: - return cls._celsius_to_fahrenheit(value) + return cls._celsius_to_fahrenheit if to_unit == UnitOfTemperature.KELVIN: - return cls._celsius_to_kelvin(value) + return cls._celsius_to_kelvin raise HomeAssistantError( UNIT_NOT_RECOGNIZED_TEMPLATE.format(to_unit, cls.UNIT_CLASS) ) if from_unit == UnitOfTemperature.FAHRENHEIT: if to_unit == UnitOfTemperature.CELSIUS: - return cls._fahrenheit_to_celsius(value) + return cls._fahrenheit_to_celsius if to_unit == UnitOfTemperature.KELVIN: - return cls._celsius_to_kelvin(cls._fahrenheit_to_celsius(value)) + return cls._fahrenheit_to_kelvin raise HomeAssistantError( UNIT_NOT_RECOGNIZED_TEMPLATE.format(to_unit, cls.UNIT_CLASS) ) if from_unit == UnitOfTemperature.KELVIN: if to_unit == UnitOfTemperature.CELSIUS: - return cls._kelvin_to_celsius(value) + return cls._kelvin_to_celsius if to_unit == UnitOfTemperature.FAHRENHEIT: - return cls._celsius_to_fahrenheit(cls._kelvin_to_celsius(value)) + return cls._kelvin_to_fahrenheit raise HomeAssistantError( UNIT_NOT_RECOGNIZED_TEMPLATE.format(to_unit, cls.UNIT_CLASS) ) @@ -393,7 +442,17 @@ class TemperatureConverter(BaseUnitConverter): """ # We use BaseUnitConverter implementation here because we are only interested # in the ratio between the units. - return super().convert(interval, from_unit, to_unit) + return super().converter_factory(from_unit, to_unit)(interval) + + @classmethod + def _kelvin_to_fahrenheit(cls, kelvin: float) -> float: + """Convert a temperature in Kelvin to Fahrenheit.""" + return (kelvin - 273.15) * 1.8 + 32.0 + + @classmethod + def _fahrenheit_to_kelvin(cls, fahrenheit: float) -> float: + """Convert a temperature in Fahrenheit to Kelvin.""" + return 273.15 + ((fahrenheit - 32.0) / 1.8) @classmethod def _fahrenheit_to_celsius(cls, fahrenheit: float) -> float: diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index 37f5dc77d006..2c76c9473505 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -1973,6 +1973,36 @@ async def test_change_statistics_unit( ], } + # Changing to the same unit is allowed but does nothing + await client.send_json( + { + "id": 6, + "type": "recorder/change_statistics_unit", + "statistic_id": "sensor.test", + "new_unit_of_measurement": "W", + "old_unit_of_measurement": "W", + } + ) + response = await client.receive_json() + assert response["success"] + await async_recorder_block_till_done(hass) + + await client.send_json({"id": 7, "type": "recorder/list_statistic_ids"}) + response = await client.receive_json() + assert response["success"] + assert response["result"] == [ + { + "statistic_id": "sensor.test", + "display_unit_of_measurement": "kW", + "has_mean": True, + "has_sum": False, + "name": None, + "source": "recorder", + "statistics_unit_of_measurement": "W", + "unit_class": "power", + } + ] + async def test_change_statistics_unit_errors( recorder_mock: Recorder, diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index 1aea64201b53..18f0c9a12c17 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -2,6 +2,7 @@ from __future__ import annotations import inspect +from itertools import chain import pytest @@ -534,6 +535,86 @@ def test_unit_conversion( assert converter.convert(value, from_unit, to_unit) == pytest.approx(expected) +@pytest.mark.parametrize( + ("converter", "value", "from_unit", "expected", "to_unit"), + [ + # Process all items in _CONVERTED_VALUE + (converter, value, from_unit, expected, to_unit) + for converter, item in _CONVERTED_VALUE.items() + for value, from_unit, expected, to_unit in item + ], +) +def test_unit_conversion_factory( + converter: type[BaseUnitConverter], + value: float, + from_unit: str, + expected: float, + to_unit: str, +) -> None: + """Test conversion to other units.""" + assert converter.converter_factory(from_unit, to_unit)(value) == pytest.approx( + expected + ) + + +def test_unit_conversion_factory_allow_none_with_none() -> None: + """Test test_unit_conversion_factory_allow_none with None.""" + assert ( + SpeedConverter.converter_factory_allow_none( + UnitOfSpeed.FEET_PER_SECOND, UnitOfSpeed.FEET_PER_SECOND + )(1) + == 1 + ) + assert ( + SpeedConverter.converter_factory_allow_none( + UnitOfSpeed.FEET_PER_SECOND, UnitOfSpeed.FEET_PER_SECOND + )(None) + is None + ) + assert ( + TemperatureConverter.converter_factory_allow_none( + UnitOfTemperature.CELSIUS, UnitOfTemperature.CELSIUS + )(1) + == 1 + ) + assert ( + TemperatureConverter.converter_factory_allow_none( + UnitOfTemperature.CELSIUS, UnitOfTemperature.CELSIUS + )(None) + is None + ) + + +@pytest.mark.parametrize( + ("converter", "value", "from_unit", "expected", "to_unit"), + chain( + [ + # Process all items in _CONVERTED_VALUE + (converter, value, from_unit, expected, to_unit) + for converter, item in _CONVERTED_VALUE.items() + for value, from_unit, expected, to_unit in item + ], + [ + # Process all items in _CONVERTED_VALUE and replace the value with None + (converter, None, from_unit, None, to_unit) + for converter, item in _CONVERTED_VALUE.items() + for value, from_unit, expected, to_unit in item + ], + ), +) +def test_unit_conversion_factory_allow_none( + converter: type[BaseUnitConverter], + value: float, + from_unit: str, + expected: float, + to_unit: str, +) -> None: + """Test conversion to other units.""" + assert converter.converter_factory_allow_none(from_unit, to_unit)( + value + ) == pytest.approx(expected) + + @pytest.mark.parametrize( ("value", "from_unit", "expected", "to_unit"), [ From 6cc5bee960518fd7e554c7e878e5937d584f2d62 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 29 May 2023 13:53:52 -0500 Subject: [PATCH 652/822] Fix onvif setup failing when unable to parse camera time (#93677) --- homeassistant/components/onvif/device.py | 46 +++++++++++++++--------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/onvif/device.py b/homeassistant/components/onvif/device.py index e470b3c700bf..a524d8ea5198 100644 --- a/homeassistant/components/onvif/device.py +++ b/homeassistant/components/onvif/device.py @@ -23,7 +23,7 @@ from homeassistant.const import ( CONF_USERNAME, Platform, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback import homeassistant.util.dt as dt_util from .const import ( @@ -189,14 +189,19 @@ class ONVIFDevice: dt_param.DateTimeType = "Manual" # Retrieve DST setting from system dt_param.DaylightSavings = bool(time.localtime().tm_isdst) - dt_param.UTCDateTime = device_time.UTCDateTime + dt_param.UTCDateTime = { + "Date": { + "Year": system_date.year, + "Month": system_date.month, + "Day": system_date.day, + }, + "Time": { + "Hour": system_date.hour, + "Minute": system_date.minute, + "Second": system_date.second, + }, + } # Retrieve timezone from system - dt_param.UTCDateTime.Date.Year = system_date.year - dt_param.UTCDateTime.Date.Month = system_date.month - dt_param.UTCDateTime.Date.Day = system_date.day - dt_param.UTCDateTime.Time.Hour = system_date.hour - dt_param.UTCDateTime.Time.Minute = system_date.minute - dt_param.UTCDateTime.Time.Second = system_date.second system_timezone = str(system_date.astimezone().tzinfo) timezone_names: list[str | None] = [system_timezone] if (time_zone := device_time.TimeZone) and system_timezone != time_zone.TZ: @@ -283,6 +288,22 @@ class ONVIFDevice: if abs(self._dt_diff_seconds) < 5: return + if device_time.DateTimeType != "Manual": + self._async_log_time_out_of_sync(cam_date_utc, system_date) + return + + # Set Date and Time ourselves if Date and Time is set manually in the camera. + try: + await self.async_manually_set_date_and_time() + except (RequestError, TransportError, IndexError, Fault): + LOGGER.warning("%s: Could not sync date/time on this camera", self.name) + self._async_log_time_out_of_sync(cam_date_utc, system_date) + + @callback + def _async_log_time_out_of_sync( + self, cam_date_utc: dt.datetime, system_date: dt.datetime + ) -> None: + """Log a warning if the camera and system date/time are not synced.""" LOGGER.warning( ( "The date/time on %s (UTC) is '%s', " @@ -294,15 +315,6 @@ class ONVIFDevice: system_date, ) - if device_time.DateTimeType != "Manual": - return - - # Set Date and Time ourselves if Date and Time is set manually in the camera. - try: - await self.async_manually_set_date_and_time() - except (RequestError, TransportError, IndexError, Fault): - LOGGER.warning("%s: Could not sync date/time on this camera", self.name) - async def async_get_device_info(self) -> DeviceInfo: """Obtain information about this device.""" device_mgmt = await self.device.create_devicemgmt_service() From 8896b27f70deeb7d5b3ecdc643135735a4f2a09f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 29 May 2023 20:57:18 +0200 Subject: [PATCH 653/822] Add empty config schema to integrations p-s (#93700) --- .../components/persistent_notification/__init__.py | 2 ++ homeassistant/components/repairs/__init__.py | 2 ++ homeassistant/components/safe_mode/__init__.py | 3 +++ homeassistant/components/search/__init__.py | 8 +++++++- homeassistant/components/ssdp/__init__.py | 5 ++++- homeassistant/components/sun/__init__.py | 5 ++++- homeassistant/components/system_health/__init__.py | 8 +++++++- 7 files changed, 29 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/persistent_notification/__init__.py b/homeassistant/components/persistent_notification/__init__.py index 042a6acfabb9..81a9bc9de4d7 100644 --- a/homeassistant/components/persistent_notification/__init__.py +++ b/homeassistant/components/persistent_notification/__init__.py @@ -63,6 +63,8 @@ SCHEMA_SERVICE_NOTIFICATION = vol.Schema( _LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + @bind_hass def create( diff --git a/homeassistant/components/repairs/__init__.py b/homeassistant/components/repairs/__init__.py index aa578c098d50..228972e8718f 100644 --- a/homeassistant/components/repairs/__init__.py +++ b/homeassistant/components/repairs/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from . import issue_handler, websocket_api @@ -16,6 +17,7 @@ __all__ = [ "RepairsFlow", "RepairsFlowManager", ] +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) def repairs_flow_manager(hass: HomeAssistant) -> RepairsFlowManager | None: diff --git a/homeassistant/components/safe_mode/__init__.py b/homeassistant/components/safe_mode/__init__.py index 2f010fe79c9d..3ed2d4476af9 100644 --- a/homeassistant/components/safe_mode/__init__.py +++ b/homeassistant/components/safe_mode/__init__.py @@ -1,10 +1,13 @@ """The Safe Mode integration.""" from homeassistant.components import persistent_notification from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType DOMAIN = "safe_mode" +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Safe Mode component.""" diff --git a/homeassistant/components/search/__init__.py b/homeassistant/components/search/__init__.py index b574081d5d42..a603d12e8c4f 100644 --- a/homeassistant/components/search/__init__.py +++ b/homeassistant/components/search/__init__.py @@ -10,13 +10,19 @@ import voluptuous as vol from homeassistant.components import automation, group, person, script, websocket_api from homeassistant.components.homeassistant import scene from homeassistant.core import HomeAssistant, callback, split_entity_id -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, +) from homeassistant.helpers.entity import entity_sources as get_entity_sources from homeassistant.helpers.typing import ConfigType DOMAIN = "search" _LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Search component.""" diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index 570e79e49931..e448fe066c48 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -48,7 +48,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback as core_callback from homeassistant.data_entry_flow import BaseServiceInfo -from homeassistant.helpers import discovery_flow +from homeassistant.helpers import config_validation as cv, discovery_flow from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.instance_id import async_get as async_get_instance_id @@ -106,6 +106,9 @@ PRIMARY_MATCH_KEYS = [ _LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + + @dataclass(slots=True) class SsdpServiceInfo(BaseServiceInfo): """Prepared info from ssdp/upnp entries.""" diff --git a/homeassistant/components/sun/__init__.py b/homeassistant/components/sun/__init__.py index a43bf4fd8082..a2378136a06a 100644 --- a/homeassistant/components/sun/__init__.py +++ b/homeassistant/components/sun/__init__.py @@ -15,7 +15,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback -from homeassistant.helpers import event +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, @@ -80,6 +80,9 @@ _PHASE_UPDATES = { } +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Track the state of the sun.""" hass.async_create_task( diff --git a/homeassistant/components/system_health/__init__.py b/homeassistant/components/system_health/__init__.py index 3d149b3a40de..9a222d7096ca 100644 --- a/homeassistant/components/system_health/__init__.py +++ b/homeassistant/components/system_health/__init__.py @@ -14,7 +14,11 @@ import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import aiohttp_client, integration_platform +from homeassistant.helpers import ( + aiohttp_client, + config_validation as cv, + integration_platform, +) from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass @@ -24,6 +28,8 @@ DOMAIN = "system_health" INFO_CALLBACK_TIMEOUT = 5 +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + @bind_hass @callback From 9c01ea003c8d705180bc71bf7456599cd8607a5e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 29 May 2023 20:58:00 +0200 Subject: [PATCH 654/822] Add empty config schema to integrations i-o (#93698) --- homeassistant/components/image_upload/__init__.py | 4 +++- homeassistant/components/intent/__init__.py | 2 ++ homeassistant/components/keyboard/__init__.py | 3 +++ homeassistant/components/kitchen_sink/__init__.py | 3 +++ homeassistant/components/lirc/__init__.py | 3 +++ homeassistant/components/map/__init__.py | 3 +++ homeassistant/components/media_source/__init__.py | 4 ++++ homeassistant/components/my/__init__.py | 3 +++ homeassistant/components/network/__init__.py | 4 ++++ homeassistant/components/onboarding/__init__.py | 3 +++ homeassistant/components/otbr/__init__.py | 3 +++ 11 files changed, 34 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/image_upload/__init__.py b/homeassistant/components/image_upload/__init__.py index 17c40cfc8753..766be89f0d4a 100644 --- a/homeassistant/components/image_upload/__init__.py +++ b/homeassistant/components/image_upload/__init__.py @@ -17,7 +17,7 @@ from homeassistant.components.http.static import CACHE_HEADERS from homeassistant.components.http.view import HomeAssistantView from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import collection +from homeassistant.helpers import collection, config_validation as cv from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType import homeassistant.util.dt as dt_util @@ -38,6 +38,8 @@ UPDATE_FIELDS = { vol.Optional("name"): vol.All(str, vol.Length(min=1)), } +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Image integration.""" diff --git a/homeassistant/components/intent/__init__.py b/homeassistant/components/intent/__init__.py index 2f5ea26a8a65..1f390d35370c 100644 --- a/homeassistant/components/intent/__init__.py +++ b/homeassistant/components/intent/__init__.py @@ -29,6 +29,8 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Intent component.""" diff --git a/homeassistant/components/keyboard/__init__.py b/homeassistant/components/keyboard/__init__.py index cdd80119bc5d..f4e7f9e04248 100644 --- a/homeassistant/components/keyboard/__init__.py +++ b/homeassistant/components/keyboard/__init__.py @@ -11,12 +11,15 @@ from homeassistant.const import ( SERVICE_VOLUME_UP, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType DOMAIN = "keyboard" TAP_KEY_SCHEMA = vol.Schema({}) +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + def setup(hass: HomeAssistant, config: ConfigType) -> bool: """Listen for keyboard events.""" diff --git a/homeassistant/components/kitchen_sink/__init__.py b/homeassistant/components/kitchen_sink/__init__.py index 3b7b96e90b6f..39143c8b84b7 100644 --- a/homeassistant/components/kitchen_sink/__init__.py +++ b/homeassistant/components/kitchen_sink/__init__.py @@ -18,6 +18,7 @@ from homeassistant.components.recorder.statistics import ( from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import Platform, UnitOfEnergy, UnitOfTemperature, UnitOfVolume from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType import homeassistant.util.dt as dt_util @@ -27,6 +28,8 @@ DOMAIN = "kitchen_sink" COMPONENTS_WITH_DEMO_PLATFORM = [Platform.SENSOR, Platform.LOCK] +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the demo environment.""" diff --git a/homeassistant/components/lirc/__init__.py b/homeassistant/components/lirc/__init__.py index cf76213a88e2..181783b6bbdc 100644 --- a/homeassistant/components/lirc/__init__.py +++ b/homeassistant/components/lirc/__init__.py @@ -8,6 +8,7 @@ import lirc from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) @@ -20,6 +21,8 @@ EVENT_IR_COMMAND_RECEIVED = "ir_command_received" ICON = "mdi:remote" +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + def setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the LIRC capability.""" diff --git a/homeassistant/components/map/__init__.py b/homeassistant/components/map/__init__.py index 0ea1fbf2af93..a3ba65be7db1 100644 --- a/homeassistant/components/map/__init__.py +++ b/homeassistant/components/map/__init__.py @@ -1,10 +1,13 @@ """Support for showing device locations.""" from homeassistant.components import frontend from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType DOMAIN = "map" +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Register the built-in map panel.""" diff --git a/homeassistant/components/media_source/__init__.py b/homeassistant/components/media_source/__init__.py index 21c32c9137fe..f3c5c92eaa6b 100644 --- a/homeassistant/components/media_source/__init__.py +++ b/homeassistant/components/media_source/__init__.py @@ -18,6 +18,7 @@ from homeassistant.components.media_player.browse_media import ( ) from homeassistant.components.websocket_api import ActiveConnection from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.frame import report from homeassistant.helpers.integration_platform import ( async_process_integration_platforms, @@ -53,6 +54,9 @@ __all__ = [ ] +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + + def is_media_source_id(media_content_id: str) -> bool: """Test if identifier is a media source.""" return URI_SCHEME_REGEX.match(media_content_id) is not None diff --git a/homeassistant/components/my/__init__.py b/homeassistant/components/my/__init__.py index b547662d1889..d699e42e1050 100644 --- a/homeassistant/components/my/__init__.py +++ b/homeassistant/components/my/__init__.py @@ -1,11 +1,14 @@ """Support for my.home-assistant.io redirect service.""" from homeassistant.components import frontend from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType DOMAIN = "my" URL_PATH = "_my_redirect" +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Register hidden _my_redirect panel.""" diff --git a/homeassistant/components/network/__init__.py b/homeassistant/components/network/__init__.py index a57334d2531f..30ff2280408d 100644 --- a/homeassistant/components/network/__init__.py +++ b/homeassistant/components/network/__init__.py @@ -6,11 +6,13 @@ import logging from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType from homeassistant.loader import bind_hass from . import util from .const import ( + DOMAIN, IPV4_BROADCAST_ADDR, LOOPBACK_TARGET_IP, MDNS_TARGET_IP, @@ -21,6 +23,8 @@ from .network import Network, async_get_network _LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + @bind_hass async def async_get_adapters(hass: HomeAssistant) -> list[Adapter]: diff --git a/homeassistant/components/onboarding/__init__.py b/homeassistant/components/onboarding/__init__.py index c36f19fd28d9..d334a0051c3d 100644 --- a/homeassistant/components/onboarding/__init__.py +++ b/homeassistant/components/onboarding/__init__.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass @@ -19,6 +20,8 @@ from .const import ( STORAGE_KEY = DOMAIN STORAGE_VERSION = 4 +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + class OnboadingStorage(Store): """Store onboarding data.""" diff --git a/homeassistant/components/otbr/__init__.py b/homeassistant/components/otbr/__init__.py index 5942d67b50d5..8f8810b5f33b 100644 --- a/homeassistant/components/otbr/__init__.py +++ b/homeassistant/components/otbr/__init__.py @@ -10,6 +10,7 @@ from homeassistant.components.thread import async_add_dataset from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType @@ -17,6 +18,8 @@ from . import websocket_api from .const import DOMAIN from .util import OTBRData, update_issues +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Open Thread Border Router component.""" From ef68f2c3ff0fc73f5755a6d09f949b5c142ae0e4 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 29 May 2023 21:00:08 +0200 Subject: [PATCH 655/822] Add empty config schema to integrations d-h (#93645) --- homeassistant/components/default_config/__init__.py | 3 +++ homeassistant/components/demo/__init__.py | 3 +++ homeassistant/components/device_automation/__init__.py | 2 ++ homeassistant/components/dhcp/__init__.py | 6 +++++- homeassistant/components/diagnostics/__init__.py | 5 ++++- homeassistant/components/energy/__init__.py | 4 +++- homeassistant/components/file_upload/__init__.py | 3 +++ homeassistant/components/hardware/__init__.py | 3 +++ homeassistant/components/homeassistant/__init__.py | 2 ++ homeassistant/components/homeassistant_alerts/__init__.py | 3 +++ homeassistant/components/homeassistant_hardware/__init__.py | 4 ++++ 11 files changed, 35 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/default_config/__init__.py b/homeassistant/components/default_config/__init__.py index d91d06949e69..25a9ca311e80 100644 --- a/homeassistant/components/default_config/__init__.py +++ b/homeassistant/components/default_config/__init__.py @@ -1,11 +1,14 @@ """Component providing default configuration for new users.""" from homeassistant.components.hassio import is_hassio from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_setup_component DOMAIN = "default_config" +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Initialize default configuration.""" diff --git a/homeassistant/components/demo/__init__.py b/homeassistant/components/demo/__init__.py index b941d31898cf..3d271b54bc93 100644 --- a/homeassistant/components/demo/__init__.py +++ b/homeassistant/components/demo/__init__.py @@ -14,6 +14,7 @@ from homeassistant.const import ( ) import homeassistant.core as ha from homeassistant.core import Event, HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.typing import ConfigType @@ -56,6 +57,8 @@ COMPONENTS_WITH_DEMO_PLATFORM = [ Platform.DEVICE_TRACKER, ] +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the demo environment.""" diff --git a/homeassistant/components/device_automation/__init__.py b/homeassistant/components/device_automation/__init__.py index d5017ac2329e..71acc6dfa79a 100644 --- a/homeassistant/components/device_automation/__init__.py +++ b/homeassistant/components/device_automation/__init__.py @@ -57,6 +57,8 @@ if TYPE_CHECKING: DOMAIN = "device_automation" +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + DEVICE_TRIGGER_BASE_SCHEMA: vol.Schema = cv.TRIGGER_BASE_SCHEMA.extend( { vol.Required(CONF_PLATFORM): "device", diff --git a/homeassistant/components/dhcp/__init__.py b/homeassistant/components/dhcp/__init__.py index 7f41d2c1d3d3..4a9f6c2b163d 100644 --- a/homeassistant/components/dhcp/__init__.py +++ b/homeassistant/components/dhcp/__init__.py @@ -42,7 +42,7 @@ from homeassistant.const import ( ) from homeassistant.core import Event, HomeAssistant, State, callback from homeassistant.data_entry_flow import BaseServiceInfo -from homeassistant.helpers import discovery_flow +from homeassistant.helpers import config_validation as cv, discovery_flow from homeassistant.helpers.device_registry import ( CONNECTION_NETWORK_MAC, DeviceRegistry, @@ -59,10 +59,14 @@ 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 + if TYPE_CHECKING: from scapy.packet import Packet from scapy.sendrecv import AsyncSniffer +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + FILTER = "udp and (port 67 or 68)" REQUESTED_ADDR = "requested_addr" MESSAGE_TYPE = "message-type" diff --git a/homeassistant/components/diagnostics/__init__.py b/homeassistant/components/diagnostics/__init__.py index 2b06c4e5d619..2ff220b90966 100644 --- a/homeassistant/components/diagnostics/__init__.py +++ b/homeassistant/components/diagnostics/__init__.py @@ -14,7 +14,7 @@ import voluptuous as vol from homeassistant.components import http, websocket_api from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import integration_platform +from homeassistant.helpers import config_validation as cv, integration_platform from homeassistant.helpers.device_registry import DeviceEntry, async_get from homeassistant.helpers.json import ( ExtendedJSONEncoder, @@ -33,6 +33,9 @@ __all__ = ["REDACTED", "async_redact_data"] _LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + + @dataclass(slots=True) class DiagnosticsPlatformData: """Diagnostic platform data.""" diff --git a/homeassistant/components/energy/__init__.py b/homeassistant/components/energy/__init__.py index 41598b39b729..7f86b2458cbd 100644 --- a/homeassistant/components/energy/__init__.py +++ b/homeassistant/components/energy/__init__.py @@ -4,13 +4,15 @@ from __future__ import annotations from homeassistant.components import frontend from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType from . import websocket_api from .const import DOMAIN from .data import async_get_manager +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def is_configured(hass: HomeAssistant) -> bool: """Return a boolean to indicate if energy is configured.""" diff --git a/homeassistant/components/file_upload/__init__.py b/homeassistant/components/file_upload/__init__.py index 73f8465b1df7..8c594f7f85ca 100644 --- a/homeassistant/components/file_upload/__init__.py +++ b/homeassistant/components/file_upload/__init__.py @@ -17,6 +17,7 @@ from homeassistant.components.http import HomeAssistantView from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from homeassistant.util import raise_if_invalid_filename from homeassistant.util.ulid import ulid_hex @@ -27,6 +28,8 @@ ONE_MEGABYTE = 1024 * 1024 MAX_SIZE = 100 * ONE_MEGABYTE TEMP_DIR_NAME = f"home-assistant-{DOMAIN}" +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + @contextmanager def process_uploaded_file(hass: HomeAssistant, file_id: str) -> Iterator[Path]: diff --git a/homeassistant/components/hardware/__init__.py b/homeassistant/components/hardware/__init__.py index a11985342131..2e00771199cd 100644 --- a/homeassistant/components/hardware/__init__.py +++ b/homeassistant/components/hardware/__init__.py @@ -2,11 +2,14 @@ from __future__ import annotations from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from . import websocket_api from .const import DOMAIN +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Hardware.""" diff --git a/homeassistant/components/homeassistant/__init__.py b/homeassistant/components/homeassistant/__init__.py index 987a4317ba84..9befec8910f5 100644 --- a/homeassistant/components/homeassistant/__init__.py +++ b/homeassistant/components/homeassistant/__init__.py @@ -60,6 +60,8 @@ SCHEMA_RELOAD_CONFIG_ENTRY = vol.All( SHUTDOWN_SERVICES = (SERVICE_HOMEASSISTANT_STOP, SERVICE_HOMEASSISTANT_RESTART) +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: ha.HomeAssistant, config: ConfigType) -> bool: # noqa: C901 """Set up general services related to Home Assistant.""" diff --git a/homeassistant/components/homeassistant_alerts/__init__.py b/homeassistant/components/homeassistant_alerts/__init__.py index 234f5ae4fed9..8241c1712654 100644 --- a/homeassistant/components/homeassistant_alerts/__init__.py +++ b/homeassistant/components/homeassistant_alerts/__init__.py @@ -12,6 +12,7 @@ from awesomeversion import AwesomeVersion, AwesomeVersionStrategy from homeassistant.components.hassio import get_supervisor_info, is_hassio from homeassistant.const import EVENT_COMPONENT_LOADED, __version__ from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.issue_registry import ( @@ -28,6 +29,8 @@ DOMAIN = "homeassistant_alerts" UPDATE_INTERVAL = timedelta(hours=3) _LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up alerts.""" diff --git a/homeassistant/components/homeassistant_hardware/__init__.py b/homeassistant/components/homeassistant_hardware/__init__.py index f3a63a7f7677..057cdd3b0db8 100644 --- a/homeassistant/components/homeassistant_hardware/__init__.py +++ b/homeassistant/components/homeassistant_hardware/__init__.py @@ -2,8 +2,12 @@ from __future__ import annotations from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType +DOMAIN = "homeassistant_hardware" +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the component.""" From 8b662dc94fecc3638016108326af9696ce6298ad Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 29 May 2023 21:01:47 +0200 Subject: [PATCH 656/822] Detect attempt to setup config entry integration via YAML (#93589) --- homeassistant/helpers/config_validation.py | 2 +- homeassistant/setup.py | 14 ++++++ tests/helpers/test_config_validation.py | 8 +++- tests/test_setup.py | 54 ++++++++++++++++++++++ 4 files changed, 75 insertions(+), 3 deletions(-) diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 78c63049e5f9..6551b7e4709d 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -866,7 +866,7 @@ def _deprecated_or_removed( logger_func(warning, *arguments) value = config[key] - if replacement_key: + if replacement_key or option_removed: config.pop(key) else: value = default diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 36b17690d7e8..f0a44eaeeced 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -222,6 +222,20 @@ async def _async_setup_component( log_error("Invalid config.") return False + # Detect attempt to setup integration which can be setup only from config entry + if ( + domain in processed_config + and not hasattr(component, "async_setup") + and not hasattr(component, "setup") + ): + _LOGGER.error( + ( + "The %s integration does not support YAML setup, please remove it from " + "your configuration" + ), + domain, + ) + start = timer() _LOGGER.info("Setting up %s", domain) with async_start_setup(hass, [domain]): diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index df8e989d672c..580828388418 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -1048,7 +1048,10 @@ def test_deprecated_or_removed_logger_with_config_attributes( setattr(config, "__config_file__", file) setattr(config, "__line__", line) - cv.deprecated("mars", replacement_key=replacement_key, default=False)(config) + validated = cv.deprecated("mars", replacement_key=replacement_key, default=False)( + config + ) + assert "mars" not in validated # Removed because a replacement_key is defined assert len(caplog.records) == 1 assert replacement in caplog.text @@ -1063,7 +1066,8 @@ def test_deprecated_or_removed_logger_with_config_attributes( setattr(config, "__config_file__", file) setattr(config, "__line__", line) - cv.removed("mars", default=False, raise_if_present=False)(config) + validated = cv.removed("mars", default=False, raise_if_present=False)(config) + assert "mars" not in validated # Removed because by cv.removed assert len(caplog.records) == 1 assert replacement in caplog.text diff --git a/tests/test_setup.py b/tests/test_setup.py index a624929d8ff4..eb4c645ecb12 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -728,3 +728,57 @@ async def test_async_start_setup_platforms(hass: HomeAssistant) -> None: assert "august" not in hass.data[setup.DATA_SETUP_STARTED] assert isinstance(hass.data[setup.DATA_SETUP_TIME]["august"], datetime.timedelta) assert "sensor" not in hass.data[setup.DATA_SETUP_TIME] + + +async def test_setup_config_entry_from_yaml( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test attempting to setup an integration which only supports config_entries.""" + expected_warning = ( + "The test_integration_only_entry integration does not support YAML setup, " + "please remove it from your configuration" + ) + + mock_integration( + hass, + MockModule( + "test_integration_only_entry", + setup=False, + async_setup_entry=AsyncMock(return_value=True), + ), + ) + + assert await setup.async_setup_component(hass, "test_integration_only_entry", {}) + assert expected_warning not in caplog.text + caplog.clear() + hass.data.pop(setup.DATA_SETUP) + hass.config.components.remove("test_integration_only_entry") + + # There should be a warning, but setup should not fail + assert await setup.async_setup_component( + hass, "test_integration_only_entry", {"test_integration_only_entry": None} + ) + assert expected_warning in caplog.text + caplog.clear() + hass.data.pop(setup.DATA_SETUP) + hass.config.components.remove("test_integration_only_entry") + + # There should be a warning, but setup should not fail + assert await setup.async_setup_component( + hass, "test_integration_only_entry", {"test_integration_only_entry": {}} + ) + assert expected_warning in caplog.text + caplog.clear() + hass.data.pop(setup.DATA_SETUP) + hass.config.components.remove("test_integration_only_entry") + + # There should be a warning, but setup should not fail + assert await setup.async_setup_component( + hass, + "test_integration_only_entry", + {"test_integration_only_entry": {"hello": "world"}}, + ) + assert expected_warning in caplog.text + caplog.clear() + hass.data.pop(setup.DATA_SETUP) + hass.config.components.remove("test_integration_only_entry") From 6aa01e14417702d775654efc1d23dc9a99d9722c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Mon, 29 May 2023 21:58:53 +0200 Subject: [PATCH 657/822] Add Aidoo sensors to Airzone Cloud (#93541) --- .../components/airzone_cloud/entity.py | 41 ++++++++++++- .../components/airzone_cloud/sensor.py | 61 +++++++++++++++++-- .../airzone_cloud/test_config_flow.py | 6 +- .../airzone_cloud/test_coordinator.py | 6 +- .../airzone_cloud/test_diagnostics.py | 2 + tests/components/airzone_cloud/test_sensor.py | 4 ++ tests/components/airzone_cloud/util.py | 53 +++++++++++++++- 7 files changed, 159 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/airzone_cloud/entity.py b/homeassistant/components/airzone_cloud/entity.py index bf02bf1ccf82..e2a3142f1f5a 100644 --- a/homeassistant/components/airzone_cloud/entity.py +++ b/homeassistant/components/airzone_cloud/entity.py @@ -4,7 +4,13 @@ from __future__ import annotations from abc import ABC, abstractmethod from typing import Any -from aioairzone_cloud.const import AZD_NAME, AZD_SYSTEM_ID, AZD_ZONES +from aioairzone_cloud.const import ( + AZD_AIDOOS, + AZD_NAME, + AZD_SYSTEM_ID, + AZD_WEBSERVER, + AZD_ZONES, +) from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.entity import DeviceInfo @@ -22,6 +28,36 @@ class AirzoneEntity(CoordinatorEntity[AirzoneUpdateCoordinator], ABC): """Return Airzone Cloud entity value by key.""" +class AirzoneAidooEntity(AirzoneEntity): + """Define an Airzone Cloud Aidoo entity.""" + + def __init__( + self, + coordinator: AirzoneUpdateCoordinator, + entry: ConfigEntry, + aidoo_id: str, + aidoo_data: dict[str, Any], + ) -> None: + """Initialize.""" + super().__init__(coordinator) + + self.aidoo_id = aidoo_id + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{entry.unique_id}_{aidoo_id}")}, + manufacturer=MANUFACTURER, + name=aidoo_data[AZD_NAME], + via_device=(DOMAIN, f"{entry.unique_id}_{aidoo_data[AZD_WEBSERVER]}"), + ) + + def get_airzone_value(self, key: str) -> Any: + """Return Aidoo value by key.""" + value = None + if aidoo := self.coordinator.data[AZD_AIDOOS].get(self.aidoo_id): + value = aidoo.get(key) + return value + + class AirzoneZoneEntity(AirzoneEntity): """Define an Airzone Cloud Zone entity.""" @@ -49,6 +85,5 @@ class AirzoneZoneEntity(AirzoneEntity): """Return zone value by key.""" value = None if zone := self.coordinator.data[AZD_ZONES].get(self.zone_id): - if key in zone: - value = zone[key] + value = zone.get(key) return value diff --git a/homeassistant/components/airzone_cloud/sensor.py b/homeassistant/components/airzone_cloud/sensor.py index 0afabfcb7159..faeb62576fd5 100644 --- a/homeassistant/components/airzone_cloud/sensor.py +++ b/homeassistant/components/airzone_cloud/sensor.py @@ -3,7 +3,13 @@ from __future__ import annotations from typing import Any, Final -from aioairzone_cloud.const import AZD_HUMIDITY, AZD_NAME, AZD_TEMP, AZD_ZONES +from aioairzone_cloud.const import ( + AZD_AIDOOS, + AZD_HUMIDITY, + AZD_NAME, + AZD_TEMP, + AZD_ZONES, +) from homeassistant.components.sensor import ( SensorDeviceClass, @@ -18,7 +24,17 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN from .coordinator import AirzoneUpdateCoordinator -from .entity import AirzoneEntity, AirzoneZoneEntity +from .entity import AirzoneAidooEntity, AirzoneEntity, AirzoneZoneEntity + +AIDOO_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = ( + SensorEntityDescription( + device_class=SensorDeviceClass.TEMPERATURE, + key=AZD_TEMP, + name="Temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + ), +) ZONE_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = ( SensorEntityDescription( @@ -42,9 +58,25 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Add Airzone Cloud sensors from a config_entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator: AirzoneUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - sensors = [] + sensors: list[AirzoneSensor] = [] + + # Aidoos + for aidoo_id, aidoo_data in coordinator.data.get(AZD_AIDOOS, {}).items(): + for description in AIDOO_SENSOR_TYPES: + if description.key in aidoo_data: + sensors.append( + AirzoneAidooSensor( + coordinator, + description, + entry, + aidoo_id, + aidoo_data, + ) + ) + + # Zones for zone_id, zone_data in coordinator.data.get(AZD_ZONES, {}).items(): for description in ZONE_SENSOR_TYPES: if description.key in zone_data: @@ -76,6 +108,27 @@ class AirzoneSensor(AirzoneEntity, SensorEntity): self._attr_native_value = self.get_airzone_value(self.entity_description.key) +class AirzoneAidooSensor(AirzoneAidooEntity, AirzoneSensor): + """Define an Airzone Cloud Aidoo sensor.""" + + def __init__( + self, + coordinator: AirzoneUpdateCoordinator, + description: SensorEntityDescription, + entry: ConfigEntry, + aidoo_id: str, + aidoo_data: dict[str, Any], + ) -> None: + """Initialize.""" + super().__init__(coordinator, entry, aidoo_id, aidoo_data) + + self._attr_name = f"{aidoo_data[AZD_NAME]} {description.name}" + self._attr_unique_id = f"{entry.unique_id}_{aidoo_id}_{description.key}" + self.entity_description = description + + self._async_update_attrs() + + class AirzoneZoneSensor(AirzoneZoneEntity, AirzoneSensor): """Define an Airzone Cloud Zone sensor.""" diff --git a/tests/components/airzone_cloud/test_config_flow.py b/tests/components/airzone_cloud/test_config_flow.py index 477511b5f200..ec031d4bf257 100644 --- a/tests/components/airzone_cloud/test_config_flow.py +++ b/tests/components/airzone_cloud/test_config_flow.py @@ -14,9 +14,9 @@ from .util import ( CONFIG, GET_INSTALLATION_MOCK, GET_INSTALLATIONS_MOCK, - GET_WEBSERVER_MOCK, WS_ID, mock_get_device_status, + mock_get_webserver, ) @@ -37,7 +37,7 @@ async def test_form(hass: HomeAssistant) -> None: return_value=GET_INSTALLATIONS_MOCK, ), patch( "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_webserver", - return_value=GET_WEBSERVER_MOCK, + side_effect=mock_get_webserver, ), patch( "homeassistant.components.airzone_cloud.AirzoneCloudApi.login", return_value=None, @@ -98,7 +98,7 @@ async def test_installations_list_error(hass: HomeAssistant) -> None: side_effect=AirzoneCloudError, ), patch( "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_webserver", - return_value=GET_WEBSERVER_MOCK, + side_effect=mock_get_webserver, ), patch( "homeassistant.components.airzone_cloud.AirzoneCloudApi.login", return_value=None, diff --git a/tests/components/airzone_cloud/test_coordinator.py b/tests/components/airzone_cloud/test_coordinator.py index 3bea38698810..40b6c937ed2a 100644 --- a/tests/components/airzone_cloud/test_coordinator.py +++ b/tests/components/airzone_cloud/test_coordinator.py @@ -14,8 +14,8 @@ from .util import ( CONFIG, GET_INSTALLATION_MOCK, GET_INSTALLATIONS_MOCK, - GET_WEBSERVER_MOCK, mock_get_device_status, + mock_get_webserver, ) from tests.common import MockConfigEntry, async_fire_time_changed @@ -42,7 +42,7 @@ async def test_coordinator_client_connector_error(hass: HomeAssistant) -> None: return_value=GET_INSTALLATIONS_MOCK, ) as mock_installations, patch( "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_webserver", - return_value=GET_WEBSERVER_MOCK, + side_effect=mock_get_webserver, ) as mock_webserver, patch( "homeassistant.components.airzone_cloud.AirzoneCloudApi.login", return_value=None, @@ -53,7 +53,7 @@ async def test_coordinator_client_connector_error(hass: HomeAssistant) -> None: mock_device_status.assert_called() mock_installation.assert_awaited_once() mock_installations.assert_called_once() - mock_webserver.assert_called_once() + mock_webserver.assert_called() mock_device_status.reset_mock() mock_installation.reset_mock() diff --git a/tests/components/airzone_cloud/test_diagnostics.py b/tests/components/airzone_cloud/test_diagnostics.py index a007886c9555..730ac27325aa 100644 --- a/tests/components/airzone_cloud/test_diagnostics.py +++ b/tests/components/airzone_cloud/test_diagnostics.py @@ -7,6 +7,7 @@ from aioairzone_cloud.const import ( API_DEVICES, API_GROUPS, API_WS_ID, + AZD_AIDOOS, AZD_INSTALLATIONS, AZD_SYSTEMS, AZD_WEBSERVERS, @@ -109,6 +110,7 @@ async def test_config_entry_diagnostics( ) assert list(diag["coord_data"]) >= [ + AZD_AIDOOS, AZD_INSTALLATIONS, AZD_SYSTEMS, AZD_WEBSERVERS, diff --git a/tests/components/airzone_cloud/test_sensor.py b/tests/components/airzone_cloud/test_sensor.py index f8cbfbf2fdee..9de134d718b5 100644 --- a/tests/components/airzone_cloud/test_sensor.py +++ b/tests/components/airzone_cloud/test_sensor.py @@ -12,6 +12,10 @@ async def test_airzone_create_sensors( await async_init_integration(hass) + # Aidoos + state = hass.states.get("sensor.bron_temperature") + assert state.state == "21.0" + # Zones state = hass.states.get("sensor.dormitorio_temperature") assert state.state == "25.0" diff --git a/tests/components/airzone_cloud/util.py b/tests/components/airzone_cloud/util.py index 641a143bfde9..044cf880a16a 100644 --- a/tests/components/airzone_cloud/util.py +++ b/tests/components/airzone_cloud/util.py @@ -4,6 +4,7 @@ from typing import Any from unittest.mock import patch from aioairzone_cloud.const import ( + API_AZ_AIDOO, API_AZ_SYSTEM, API_AZ_ZONE, API_CELSIUS, @@ -38,6 +39,7 @@ from aioairzone_cloud.const import ( API_ZONE_NUMBER, ) from aioairzone_cloud.device import Device +from aioairzone_cloud.webserver import WebServer from homeassistant.components.airzone_cloud import DOMAIN from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME @@ -46,6 +48,7 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry WS_ID = "11:22:33:44:55:66" +WS_ID_AIDOO = "11:22:33:44:55:67" CONFIG = { CONF_ID: "inst1", @@ -88,6 +91,17 @@ GET_INSTALLATION_MOCK = { }, ], }, + { + API_NAME: "Aidoo Group", + API_DEVICES: [ + { + API_DEVICE_ID: "aidoo1", + API_NAME: "Bron", + API_TYPE: API_AZ_AIDOO, + API_WS_ID: WS_ID_AIDOO, + }, + ], + }, ], } @@ -98,6 +112,7 @@ GET_INSTALLATIONS_MOCK = { API_NAME: "House", API_WS_IDS: [ WS_ID, + WS_ID_AIDOO, ], }, ], @@ -120,10 +135,37 @@ GET_WEBSERVER_MOCK = { }, } +GET_WEBSERVER_MOCK_AIDOO = { + API_WS_TYPE: "ws_aidoo", + API_CONFIG: { + API_WS_FW: "3.13", + API_STAT_SSID: "Wifi", + API_STAT_CHANNEL: 1, + API_STAT_AP_MAC: "00:00:00:00:00:01", + }, + API_STATUS: { + API_IS_CONNECTED: True, + API_STAT_QUALITY: 4, + API_STAT_RSSI: -77, + API_CONNECTION_DATE: "2023-05-24 17:00:52 +0200", + API_DISCONNECTION_DATE: "2023-05-24 17:00:25 +0200", + }, +} + def mock_get_device_status(device: Device) -> dict[str, Any]: """Mock API device status.""" + if device.get_id() == "aidoo1": + return { + API_ERRORS: [], + API_IS_CONNECTED: True, + API_LOCAL_TEMP: { + API_CELSIUS: 21, + API_FAH: 70, + }, + API_WARNINGS: [], + } if device.get_id() == "system1": return { API_ERRORS: [], @@ -151,6 +193,15 @@ def mock_get_device_status(device: Device) -> dict[str, Any]: } +def mock_get_webserver(webserver: WebServer, devices: bool) -> dict[str, Any]: + """Mock API get webserver.""" + + if webserver.get_id() == WS_ID_AIDOO: + return GET_WEBSERVER_MOCK_AIDOO + + return GET_WEBSERVER_MOCK + + async def async_init_integration( hass: HomeAssistant, ) -> None: @@ -174,7 +225,7 @@ async def async_init_integration( return_value=GET_INSTALLATIONS_MOCK, ), patch( "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_webserver", - return_value=GET_WEBSERVER_MOCK, + side_effect=mock_get_webserver, ), patch( "homeassistant.components.airzone_cloud.AirzoneCloudApi.login", return_value=None, From 70c49824d72fde7bbf5b9e1002d9fcd5304e7df4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Tue, 30 May 2023 00:00:11 +0300 Subject: [PATCH 658/822] Import `util.dt` as `dt_util` in `components/[e-f]*` (#93758) --- .../components/easyenergy/coordinator.py | 6 +++--- .../components/energyzero/coordinator.py | 6 +++--- .../components/environment_canada/weather.py | 6 +++--- homeassistant/components/esphome/sensor.py | 4 ++-- tests/components/flo/test_device.py | 4 ++-- tests/components/fronius/__init__.py | 4 ++-- tests/components/fronius/test_coordinator.py | 16 +++++++++------- tests/components/fronius/test_sensor.py | 12 ++++++------ tests/components/frontend/test_init.py | 4 ++-- .../components/fully_kiosk/test_binary_sensor.py | 6 +++--- tests/components/fully_kiosk/test_number.py | 6 +++--- tests/components/fully_kiosk/test_sensor.py | 8 ++++---- 12 files changed, 42 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/easyenergy/coordinator.py b/homeassistant/components/easyenergy/coordinator.py index 3d92f63b752e..3996fd4d16ab 100644 --- a/homeassistant/components/easyenergy/coordinator.py +++ b/homeassistant/components/easyenergy/coordinator.py @@ -16,7 +16,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .const import DOMAIN, LOGGER, SCAN_INTERVAL, THRESHOLD_HOUR @@ -47,7 +47,7 @@ class EasyEnergyDataUpdateCoordinator(DataUpdateCoordinator[EasyEnergyData]): async def _async_update_data(self) -> EasyEnergyData: """Fetch data from easyEnergy.""" - today = dt.now().date() + today = dt_util.now().date() gas_today = None energy_tomorrow = None @@ -62,7 +62,7 @@ class EasyEnergyDataUpdateCoordinator(DataUpdateCoordinator[EasyEnergyData]): except EasyEnergyNoDataError: LOGGER.debug("No data for gas prices for easyEnergy integration") # Energy for tomorrow only after 14:00 UTC - if dt.utcnow().hour >= THRESHOLD_HOUR: + if dt_util.utcnow().hour >= THRESHOLD_HOUR: tomorrow = today + timedelta(days=1) try: energy_tomorrow = await self.easyenergy.energy_prices( diff --git a/homeassistant/components/energyzero/coordinator.py b/homeassistant/components/energyzero/coordinator.py index 232f14e8f8d3..a30509a3840a 100644 --- a/homeassistant/components/energyzero/coordinator.py +++ b/homeassistant/components/energyzero/coordinator.py @@ -16,7 +16,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .const import DOMAIN, LOGGER, SCAN_INTERVAL, THRESHOLD_HOUR @@ -47,7 +47,7 @@ class EnergyZeroDataUpdateCoordinator(DataUpdateCoordinator[EnergyZeroData]): async def _async_update_data(self) -> EnergyZeroData: """Fetch data from EnergyZero.""" - today = dt.now().date() + today = dt_util.now().date() gas_today = None energy_tomorrow = None @@ -62,7 +62,7 @@ class EnergyZeroDataUpdateCoordinator(DataUpdateCoordinator[EnergyZeroData]): except EnergyZeroNoDataError: LOGGER.debug("No data for gas prices for EnergyZero integration") # Energy for tomorrow only after 14:00 UTC - if dt.utcnow().hour >= THRESHOLD_HOUR: + if dt_util.utcnow().hour >= THRESHOLD_HOUR: tomorrow = today + timedelta(days=1) try: energy_tomorrow = await self.energyzero.energy_prices( diff --git a/homeassistant/components/environment_canada/weather.py b/homeassistant/components/environment_canada/weather.py index 74bf9c8ca54b..32ccfa901dbb 100644 --- a/homeassistant/components/environment_canada/weather.py +++ b/homeassistant/components/environment_canada/weather.py @@ -33,7 +33,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from . import device_info from .const import DOMAIN @@ -169,7 +169,7 @@ def get_forecast(ec_data, hourly): return None today = { - ATTR_FORECAST_TIME: dt.now().isoformat(), + ATTR_FORECAST_TIME: dt_util.now().isoformat(), ATTR_FORECAST_CONDITION: icon_code_to_condition( int(half_days[0]["icon_code"]) ), @@ -201,7 +201,7 @@ def get_forecast(ec_data, hourly): forecast_array.append( { ATTR_FORECAST_TIME: ( - dt.now() + datetime.timedelta(days=day) + dt_util.now() + datetime.timedelta(days=day) ).isoformat(), ATTR_FORECAST_NATIVE_TEMP: int(half_days[high]["temperature"]), ATTR_FORECAST_NATIVE_TEMP_LOW: int(half_days[low]["temperature"]), diff --git a/homeassistant/components/esphome/sensor.py b/homeassistant/components/esphome/sensor.py index 25a0bfaff7f5..7a1234341bea 100644 --- a/homeassistant/components/esphome/sensor.py +++ b/homeassistant/components/esphome/sensor.py @@ -21,7 +21,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from homeassistant.util.enum import try_parse_enum from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry @@ -81,7 +81,7 @@ class EsphomeSensor(EsphomeEntity[SensorInfo, SensorState], SensorEntity): if self._state.missing_state: return None if self.device_class == SensorDeviceClass.TIMESTAMP: - return dt.utc_from_timestamp(self._state.state) + return dt_util.utc_from_timestamp(self._state.state) return f"{self._state.state:.{self._static_info.accuracy_decimals}f}" @property diff --git a/tests/components/flo/test_device.py b/tests/components/flo/test_device.py index 9ad0d39a47f6..5d619f9e91f7 100644 --- a/tests/components/flo/test_device.py +++ b/tests/components/flo/test_device.py @@ -11,7 +11,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import UpdateFailed from homeassistant.setup import async_setup_component -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .common import TEST_PASSWORD, TEST_USER_ID @@ -83,7 +83,7 @@ async def test_device( call_count = aioclient_mock.call_count - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=90)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=90)) await hass.async_block_till_done() assert aioclient_mock.call_count == call_count + 6 diff --git a/tests/components/fronius/__init__.py b/tests/components/fronius/__init__.py index 4222a2037aa5..bd70604398db 100644 --- a/tests/components/fronius/__init__.py +++ b/tests/components/fronius/__init__.py @@ -6,7 +6,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed, load_fixture from tests.test_util.aiohttp import AiohttpClientMocker @@ -96,5 +96,5 @@ async def enable_all_entities(hass, config_entry_id, time_till_next_update): ]: registry.async_update_entity(entry.entity_id, **{"disabled_by": None}) await hass.async_block_till_done() - async_fire_time_changed(hass, dt.utcnow() + time_till_next_update) + async_fire_time_changed(hass, dt_util.utcnow() + time_till_next_update) await hass.async_block_till_done() diff --git a/tests/components/fronius/test_coordinator.py b/tests/components/fronius/test_coordinator.py index 6a25cee6ea0d..a0e420c5b527 100644 --- a/tests/components/fronius/test_coordinator.py +++ b/tests/components/fronius/test_coordinator.py @@ -7,7 +7,7 @@ from homeassistant.components.fronius.coordinator import ( FroniusInverterUpdateCoordinator, ) from homeassistant.core import HomeAssistant -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from . import mock_responses, setup_fronius_integration @@ -26,7 +26,7 @@ async def test_adaptive_update_interval( mock_inverter_data.reset_mock() async_fire_time_changed( - hass, dt.utcnow() + FroniusInverterUpdateCoordinator.default_interval + hass, dt_util.utcnow() + FroniusInverterUpdateCoordinator.default_interval ) await hass.async_block_till_done() mock_inverter_data.assert_called_once() @@ -36,14 +36,15 @@ async def test_adaptive_update_interval( # first 3 bad requests at default interval - 4th has different interval for _ in range(3): async_fire_time_changed( - hass, dt.utcnow() + FroniusInverterUpdateCoordinator.default_interval + hass, + dt_util.utcnow() + FroniusInverterUpdateCoordinator.default_interval, ) await hass.async_block_till_done() assert mock_inverter_data.call_count == 3 mock_inverter_data.reset_mock() async_fire_time_changed( - hass, dt.utcnow() + FroniusInverterUpdateCoordinator.error_interval + hass, dt_util.utcnow() + FroniusInverterUpdateCoordinator.error_interval ) await hass.async_block_till_done() assert mock_inverter_data.call_count == 1 @@ -52,14 +53,14 @@ async def test_adaptive_update_interval( mock_inverter_data.side_effect = None # next successful request resets to default interval async_fire_time_changed( - hass, dt.utcnow() + FroniusInverterUpdateCoordinator.error_interval + hass, dt_util.utcnow() + FroniusInverterUpdateCoordinator.error_interval ) await hass.async_block_till_done() mock_inverter_data.assert_called_once() mock_inverter_data.reset_mock() async_fire_time_changed( - hass, dt.utcnow() + FroniusInverterUpdateCoordinator.default_interval + hass, dt_util.utcnow() + FroniusInverterUpdateCoordinator.default_interval ) await hass.async_block_till_done() mock_inverter_data.assert_called_once() @@ -70,7 +71,8 @@ async def test_adaptive_update_interval( # first 3 requests at default interval - 4th has different interval for _ in range(3): async_fire_time_changed( - hass, dt.utcnow() + FroniusInverterUpdateCoordinator.default_interval + hass, + dt_util.utcnow() + FroniusInverterUpdateCoordinator.default_interval, ) await hass.async_block_till_done() # BadStatusError does 3 silent retries for inverter endpoint * 3 request intervals = 9 diff --git a/tests/components/fronius/test_sensor.py b/tests/components/fronius/test_sensor.py index 59dfeee9f5d0..47b6410a1466 100644 --- a/tests/components/fronius/test_sensor.py +++ b/tests/components/fronius/test_sensor.py @@ -8,7 +8,7 @@ from homeassistant.components.fronius.coordinator import ( from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from . import enable_all_entities, mock_responses, setup_fronius_integration @@ -43,7 +43,7 @@ async def test_symo_inverter( # Second test at daytime when inverter is producing mock_responses(aioclient_mock, night=False) async_fire_time_changed( - hass, dt.utcnow() + FroniusInverterUpdateCoordinator.default_interval + hass, dt_util.utcnow() + FroniusInverterUpdateCoordinator.default_interval ) await hass.async_block_till_done() assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 56 @@ -65,7 +65,7 @@ async def test_symo_inverter( # Third test at nighttime - additional AC entities default to 0 mock_responses(aioclient_mock, night=True) async_fire_time_changed( - hass, dt.utcnow() + FroniusInverterUpdateCoordinator.default_interval + hass, dt_util.utcnow() + FroniusInverterUpdateCoordinator.default_interval ) await hass.async_block_till_done() assert_state("sensor.symo_20_ac_current", 0) @@ -150,7 +150,7 @@ async def test_symo_power_flow( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: """Test Fronius Symo power flow entities.""" - async_fire_time_changed(hass, dt.utcnow()) + async_fire_time_changed(hass, dt_util.utcnow()) def assert_state(entity_id, expected_state): state = hass.states.get(entity_id) @@ -176,7 +176,7 @@ async def test_symo_power_flow( # Second test at daytime when inverter is producing mock_responses(aioclient_mock, night=False) async_fire_time_changed( - hass, dt.utcnow() + FroniusPowerFlowUpdateCoordinator.default_interval + hass, dt_util.utcnow() + FroniusPowerFlowUpdateCoordinator.default_interval ) await hass.async_block_till_done() # 54 because power_flow `rel_SelfConsumption` and `P_PV` is not `null` anymore @@ -193,7 +193,7 @@ async def test_symo_power_flow( # Third test at nighttime - default values are used mock_responses(aioclient_mock, night=True) async_fire_time_changed( - hass, dt.utcnow() + FroniusPowerFlowUpdateCoordinator.default_interval + hass, dt_util.utcnow() + FroniusPowerFlowUpdateCoordinator.default_interval ) await hass.async_block_till_done() assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 54 diff --git a/tests/components/frontend/test_init.py b/tests/components/frontend/test_init.py index d713fc7e6797..721f64161548 100644 --- a/tests/components/frontend/test_init.py +++ b/tests/components/frontend/test_init.py @@ -21,7 +21,7 @@ from homeassistant.components.websocket_api.const import TYPE_RESULT from homeassistant.core import HomeAssistant from homeassistant.loader import async_get_integration from homeassistant.setup import async_setup_component -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from tests.common import MockUser, async_capture_events, async_fire_time_changed from tests.typing import WebSocketGenerator @@ -228,7 +228,7 @@ async def test_themes_save_storage( ) # To trigger the call_later - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=60)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=60)) # To execute the save await hass.async_block_till_done() diff --git a/tests/components/fully_kiosk/test_binary_sensor.py b/tests/components/fully_kiosk/test_binary_sensor.py index f5cf6aca455a..5b88854b0209 100644 --- a/tests/components/fully_kiosk/test_binary_sensor.py +++ b/tests/components/fully_kiosk/test_binary_sensor.py @@ -15,7 +15,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed @@ -76,7 +76,7 @@ async def test_binary_sensors( # Test unknown/missing data mock_fully_kiosk.getDeviceInfo.return_value = {} - async_fire_time_changed(hass, dt.utcnow() + UPDATE_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + UPDATE_INTERVAL) await hass.async_block_till_done() state = hass.states.get("binary_sensor.amazon_fire_plugged_in") @@ -85,7 +85,7 @@ async def test_binary_sensors( # Test failed update mock_fully_kiosk.getDeviceInfo.side_effect = FullyKioskError("error", "status") - async_fire_time_changed(hass, dt.utcnow() + UPDATE_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + UPDATE_INTERVAL) await hass.async_block_till_done() state = hass.states.get("binary_sensor.amazon_fire_plugged_in") diff --git a/tests/components/fully_kiosk/test_number.py b/tests/components/fully_kiosk/test_number.py index 968faa3f0b4a..4843e72465c5 100644 --- a/tests/components/fully_kiosk/test_number.py +++ b/tests/components/fully_kiosk/test_number.py @@ -6,7 +6,7 @@ import homeassistant.components.number as number from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed @@ -52,7 +52,7 @@ async def test_numbers( # Test invalid numeric data mock_fully_kiosk.getSettings.return_value = {"screenBrightness": "invalid"} - async_fire_time_changed(hass, dt.utcnow() + UPDATE_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + UPDATE_INTERVAL) await hass.async_block_till_done() state = hass.states.get("number.amazon_fire_screen_brightness") @@ -61,7 +61,7 @@ async def test_numbers( # Test unknown/missing data mock_fully_kiosk.getSettings.return_value = {} - async_fire_time_changed(hass, dt.utcnow() + UPDATE_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + UPDATE_INTERVAL) await hass.async_block_till_done() state = hass.states.get("number.amazon_fire_screensaver_timer") diff --git a/tests/components/fully_kiosk/test_sensor.py b/tests/components/fully_kiosk/test_sensor.py index 6454784a2517..cc8b30640b5b 100644 --- a/tests/components/fully_kiosk/test_sensor.py +++ b/tests/components/fully_kiosk/test_sensor.py @@ -18,7 +18,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed @@ -141,7 +141,7 @@ async def test_sensors_sensors( # Test unknown/missing data mock_fully_kiosk.getDeviceInfo.return_value = {} - async_fire_time_changed(hass, dt.utcnow() + UPDATE_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + UPDATE_INTERVAL) await hass.async_block_till_done() state = hass.states.get("sensor.amazon_fire_internal_storage_free_space") @@ -150,7 +150,7 @@ async def test_sensors_sensors( # Test failed update mock_fully_kiosk.getDeviceInfo.side_effect = FullyKioskError("error", "status") - async_fire_time_changed(hass, dt.utcnow() + UPDATE_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + UPDATE_INTERVAL) await hass.async_block_till_done() state = hass.states.get("sensor.amazon_fire_internal_storage_free_space") @@ -176,7 +176,7 @@ async def test_url_sensor_truncating( mock_fully_kiosk.getDeviceInfo.return_value = { "currentPage": long_url, } - async_fire_time_changed(hass, dt.utcnow() + UPDATE_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + UPDATE_INTERVAL) await hass.async_block_till_done() state = hass.states.get("sensor.amazon_fire_current_page") From 1ce74ba25cd55a580410e6e9f676e468d5387c25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Tue, 30 May 2023 00:00:43 +0300 Subject: [PATCH 659/822] Import `util.dt` as `dt_util` in `components/[t-z]*` (#93763) --- homeassistant/components/todoist/calendar.py | 30 +++++++++---------- .../trafikverket_ferry/coordinator.py | 12 ++++---- .../components/trafikverket_train/sensor.py | 18 ++++++----- homeassistant/components/vallox/sensor.py | 4 +-- homeassistant/components/withings/common.py | 6 ++-- .../components/workday/binary_sensor.py | 8 ++--- .../components/workday/config_flow.py | 8 ++--- homeassistant/components/xiaomi_miio/light.py | 10 +++---- homeassistant/components/zha/config_flow.py | 6 ++-- tests/components/tasmota/test_sensor.py | 7 +++-- tests/components/todoist/test_calendar.py | 12 +++++--- .../totalconnect/test_alarm_control_panel.py | 28 ++++++++--------- .../components/trafikverket_ferry/conftest.py | 16 +++++----- .../trafikverket_ferry/test_coordinator.py | 16 +++++----- .../trafikverket_ferry/test_sensor.py | 6 ++-- tests/components/unifi/test_switch.py | 4 +-- .../uptimerobot/test_binary_sensor.py | 4 +-- tests/components/uptimerobot/test_init.py | 16 +++++----- tests/components/uptimerobot/test_sensor.py | 4 +-- tests/components/vallox/test_sensor.py | 12 ++++---- tests/components/version/common.py | 6 ++-- tests/components/version/test_config_flow.py | 6 ++-- tests/components/webostv/test_media_player.py | 6 ++-- tests/components/wemo/test_init.py | 4 +-- tests/components/youtube/test_sensor.py | 4 +-- 25 files changed, 134 insertions(+), 119 deletions(-) diff --git a/homeassistant/components/todoist/calendar.py b/homeassistant/components/todoist/calendar.py index 33e9e9c8d2cd..544144018dd9 100644 --- a/homeassistant/components/todoist/calendar.py +++ b/homeassistant/components/todoist/calendar.py @@ -24,7 +24,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .const import ( ALL_DAY, @@ -214,14 +214,14 @@ async def async_setup_platform( data["due_lang"] = call.data[DUE_DATE_LANG] if DUE_DATE in call.data: - due_date = dt.parse_datetime(call.data[DUE_DATE]) + due_date = dt_util.parse_datetime(call.data[DUE_DATE]) if due_date is None: - due = dt.parse_date(call.data[DUE_DATE]) + due = dt_util.parse_date(call.data[DUE_DATE]) if due is None: raise ValueError(f"Invalid due_date: {call.data[DUE_DATE]}") due_date = datetime(due.year, due.month, due.day) # Format it in the manner Todoist expects - due_date = dt.as_utc(due_date) + due_date = dt_util.as_utc(due_date) date_format = "%Y-%m-%dT%H:%M:%S" data["due_datetime"] = datetime.strftime(due_date, date_format) @@ -239,16 +239,16 @@ async def async_setup_platform( _reminder_due["lang"] = call.data[REMINDER_DATE_LANG] if REMINDER_DATE in call.data: - due_date = dt.parse_datetime(call.data[REMINDER_DATE]) + due_date = dt_util.parse_datetime(call.data[REMINDER_DATE]) if due_date is None: - due = dt.parse_date(call.data[REMINDER_DATE]) + due = dt_util.parse_date(call.data[REMINDER_DATE]) if due is None: raise ValueError( f"Invalid reminder_date: {call.data[REMINDER_DATE]}" ) due_date = datetime(due.year, due.month, due.day) # Format it in the manner Todoist expects - due_date = dt.as_utc(due_date) + due_date = dt_util.as_utc(due_date) date_format = "%Y-%m-%dT%H:%M:%S" _reminder_due["date"] = datetime.strftime(due_date, date_format) @@ -453,7 +453,7 @@ class TodoistProjectData: LABELS: [], OVERDUE: False, PRIORITY: data.priority, - START: dt.now(), + START: dt_util.now(), SUMMARY: data.content, } @@ -474,19 +474,19 @@ class TodoistProjectData: # complete the task. # Generally speaking, that means right now. if data.due is not None: - end = dt.parse_datetime( + end = dt_util.parse_datetime( data.due.datetime if data.due.datetime else data.due.date ) - task[END] = dt.as_local(end) if end is not None else end + task[END] = dt_util.as_local(end) if end is not None else end if task[END] is not None: if self._due_date_days is not None and ( - task[END] > dt.now() + self._due_date_days + task[END] > dt_util.now() + self._due_date_days ): # This task is out of range of our due date; # it shouldn't be counted. return None - task[DUE_TODAY] = task[END].date() == dt.now().date() + task[DUE_TODAY] = task[END].date() == dt_util.now().date() # Special case: Task is overdue. if task[END] <= task[START]: @@ -669,10 +669,10 @@ class TodoistProjectData: def get_start(due: Due) -> datetime | date | None: """Return the task due date as a start date or date time.""" if due.datetime: - start = dt.parse_datetime(due.datetime) + start = dt_util.parse_datetime(due.datetime) if not start: return None - return dt.as_local(start) + return dt_util.as_local(start) if due.date: - return dt.parse_date(due.date) + return dt_util.parse_date(due.date) return None diff --git a/homeassistant/components/trafikverket_ferry/coordinator.py b/homeassistant/components/trafikverket_ferry/coordinator.py index 926b57092a72..32c97b4fe0a5 100644 --- a/homeassistant/components/trafikverket_ferry/coordinator.py +++ b/homeassistant/components/trafikverket_ferry/coordinator.py @@ -15,7 +15,7 @@ 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 homeassistant.util import dt +from homeassistant.util import dt as dt_util from .const import CONF_FROM, CONF_TIME, CONF_TO, DOMAIN @@ -60,20 +60,22 @@ class TVDataUpdateCoordinator(DataUpdateCoordinator): ) self._from: str = entry.data[CONF_FROM] self._to: str = entry.data[CONF_TO] - self._time: time | None = dt.parse_time(entry.data[CONF_TIME]) + self._time: time | None = dt_util.parse_time(entry.data[CONF_TIME]) self._weekdays: list[str] = entry.data[CONF_WEEKDAY] async def _async_update_data(self) -> dict[str, Any]: """Fetch data from Trafikverket.""" departure_day = next_departuredate(self._weekdays) - current_time = dt.now() + current_time = dt_util.now() when = ( datetime.combine( - departure_day, self._time, dt.get_time_zone(self.hass.config.time_zone) + departure_day, + self._time, + dt_util.get_time_zone(self.hass.config.time_zone), ) if self._time - else dt.now() + else dt_util.now() ) if current_time > when: when = current_time diff --git a/homeassistant/components/trafikverket_train/sensor.py b/homeassistant/components/trafikverket_train/sensor.py index f48127b3e977..4ea6ff48dc1c 100644 --- a/homeassistant/components/trafikverket_train/sensor.py +++ b/homeassistant/components/trafikverket_train/sensor.py @@ -19,7 +19,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .const import CONF_FROM, CONF_TIME, CONF_TO, DOMAIN from .util import create_unique_id @@ -48,7 +48,7 @@ async def async_setup_entry( to_station = hass.data[DOMAIN][entry.entry_id][CONF_TO] from_station = hass.data[DOMAIN][entry.entry_id][CONF_FROM] get_time: str | None = entry.data.get(CONF_TIME) - train_time = dt.parse_time(get_time) if get_time else None + train_time = dt_util.parse_time(get_time) if get_time else None async_add_entities( [ @@ -89,7 +89,7 @@ def next_departuredate(departure: list[str]) -> date: def _to_iso_format(traintime: datetime) -> str: """Return isoformatted utc time.""" - return dt.as_utc(traintime).isoformat() + return dt_util.as_utc(traintime).isoformat() class TrainSensor(SensorEntity): @@ -131,12 +131,14 @@ class TrainSensor(SensorEntity): async def async_update(self) -> None: """Retrieve latest state.""" - when = dt.now() + when = dt_util.now() _state: TrainStop | None = None if self._time: departure_day = next_departuredate(self._weekday) when = datetime.combine( - departure_day, self._time, dt.get_time_zone(self.hass.config.time_zone) + departure_day, + self._time, + dt_util.get_time_zone(self.hass.config.time_zone), ) try: if self._time: @@ -162,11 +164,11 @@ class TrainSensor(SensorEntity): # The original datetime doesn't provide a timezone so therefore attaching it here. if TYPE_CHECKING: assert _state.advertised_time_at_location - self._attr_native_value = dt.as_utc(_state.advertised_time_at_location) + self._attr_native_value = dt_util.as_utc(_state.advertised_time_at_location) if _state.time_at_location: - self._attr_native_value = dt.as_utc(_state.time_at_location) + self._attr_native_value = dt_util.as_utc(_state.time_at_location) if _state.estimated_time_at_location: - self._attr_native_value = dt.as_utc(_state.estimated_time_at_location) + self._attr_native_value = dt_util.as_utc(_state.estimated_time_at_location) self._update_attributes(_state) diff --git a/homeassistant/components/vallox/sensor.py b/homeassistant/components/vallox/sensor.py index a6158ea21d44..a4f6563798da 100644 --- a/homeassistant/components/vallox/sensor.py +++ b/homeassistant/components/vallox/sensor.py @@ -21,7 +21,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 import dt +from homeassistant.util import dt as dt_util from . import ValloxDataUpdateCoordinator, ValloxEntity from .const import ( @@ -108,7 +108,7 @@ class ValloxFilterRemainingSensor(ValloxSensorEntity): return datetime.combine( next_filter_change_date, - time(hour=13, minute=0, second=0, tzinfo=dt.DEFAULT_TIME_ZONE), + time(hour=13, minute=0, second=0, tzinfo=dt_util.DEFAULT_TIME_ZONE), ) diff --git a/homeassistant/components/withings/common.py b/homeassistant/components/withings/common.py index a691ffe8f9e5..1b173e3a3778 100644 --- a/homeassistant/components/withings/common.py +++ b/homeassistant/components/withings/common.py @@ -41,7 +41,7 @@ from homeassistant.helpers.config_entry_oauth2_flow import ( ) from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from . import const from .const import Measurement @@ -411,7 +411,7 @@ class DataManager: async def async_get_measures(self) -> dict[Measurement, Any]: """Get the measures data.""" _LOGGER.debug("Updating withings measures") - now = dt.utcnow() + now = dt_util.utcnow() startdate = now - datetime.timedelta(days=7) response = await self._hass.async_add_executor_job( @@ -439,7 +439,7 @@ class DataManager: async def async_get_sleep_summary(self) -> dict[Measurement, Any]: """Get the sleep summary data.""" _LOGGER.debug("Updating withing sleep summary") - now = dt.utcnow() + now = dt_util.utcnow() yesterday = now - datetime.timedelta(days=1) yesterday_noon = datetime.datetime( yesterday.year, diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py index 9c2e453c03d3..a68a8215cb4a 100644 --- a/homeassistant/components/workday/binary_sensor.py +++ b/homeassistant/components/workday/binary_sensor.py @@ -21,7 +21,7 @@ from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .const import ( ALLOWED_DAYS, @@ -120,7 +120,7 @@ async def async_setup_entry( sensor_name: str = entry.options[CONF_NAME] workdays: list[str] = entry.options[CONF_WORKDAYS] - year: int = (dt.now() + timedelta(days=days_offset)).year + year: int = (dt_util.now() + timedelta(days=days_offset)).year obj_holidays: HolidayBase = getattr(holidays, country)(years=year) if province: @@ -140,7 +140,7 @@ async def async_setup_entry( for remove_holiday in remove_holidays: try: # is this formatted as a date? - if dt.parse_date(remove_holiday): + if dt_util.parse_date(remove_holiday): # remove holiday by date removed = obj_holidays.pop(remove_holiday) LOGGER.debug("Removed %s", remove_holiday) @@ -231,7 +231,7 @@ class IsWorkdaySensor(BinarySensorEntity): self._attr_is_on = False # Get ISO day of the week (1 = Monday, 7 = Sunday) - adjusted_date = dt.now() + timedelta(days=self._days_offset) + adjusted_date = dt_util.now() + timedelta(days=self._days_offset) day = adjusted_date.isoweekday() - 1 day_of_week = ALLOWED_DAYS[day] diff --git a/homeassistant/components/workday/config_flow.py b/homeassistant/components/workday/config_flow.py index c440ca5d73e2..7f0c7906621e 100644 --- a/homeassistant/components/workday/config_flow.py +++ b/homeassistant/components/workday/config_flow.py @@ -24,7 +24,7 @@ from homeassistant.helpers.selector import ( SelectSelectorMode, TextSelector, ) -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .const import ( ALLOWED_DAYS, @@ -73,16 +73,16 @@ 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.parse_date(add_date) is None: + if dt_util.parse_date(add_date) is None: raise AddDatesError("Incorrect date") - year: int = dt.now().year + year: int = dt_util.now().year obj_holidays = country_holidays( user_input[CONF_COUNTRY], user_input.get(CONF_PROVINCE), year ) for remove_date in user_input[CONF_REMOVE_HOLIDAYS]: - if dt.parse_date(remove_date) is None: + if dt_util.parse_date(remove_date) is None: if obj_holidays.get_named(remove_date) == []: raise RemoveDatesError("Incorrect date or name") diff --git a/homeassistant/components/xiaomi_miio/light.py b/homeassistant/components/xiaomi_miio/light.py index fc38d75d2ca1..ed1bdef9e331 100644 --- a/homeassistant/components/xiaomi_miio/light.py +++ b/homeassistant/components/xiaomi_miio/light.py @@ -38,7 +38,7 @@ from homeassistant.core import HomeAssistant, ServiceCall import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import color, dt +from homeassistant.util import color, dt as dt_util from .const import ( CONF_DEVICE, @@ -362,7 +362,7 @@ class XiaomiPhilipsGenericLight(XiaomiPhilipsAbstractLight): delayed_turn_off = self.delayed_turn_off_timestamp( state.delay_off_countdown, - dt.utcnow(), + dt_util.utcnow(), self._state_attrs[ATTR_DELAYED_TURN_OFF], ) @@ -523,7 +523,7 @@ class XiaomiPhilipsBulb(XiaomiPhilipsGenericLight): delayed_turn_off = self.delayed_turn_off_timestamp( state.delay_off_countdown, - dt.utcnow(), + dt_util.utcnow(), self._state_attrs[ATTR_DELAYED_TURN_OFF], ) @@ -582,7 +582,7 @@ class XiaomiPhilipsCeilingLamp(XiaomiPhilipsBulb): delayed_turn_off = self.delayed_turn_off_timestamp( state.delay_off_countdown, - dt.utcnow(), + dt_util.utcnow(), self._state_attrs[ATTR_DELAYED_TURN_OFF], ) @@ -625,7 +625,7 @@ class XiaomiPhilipsEyecareLamp(XiaomiPhilipsGenericLight): delayed_turn_off = self.delayed_turn_off_timestamp( state.delay_off_countdown, - dt.utcnow(), + dt_util.utcnow(), self._state_attrs[ATTR_DELAYED_TURN_OFF], ) diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 5230d77ce464..d0f124a08388 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -23,7 +23,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowHandler, FlowResult from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.selector import FileSelector, FileSelectorConfig -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .core.const import ( CONF_BAUDRATE, @@ -69,7 +69,7 @@ def _format_backup_choice( ) -> str: """Format network backup info into a short piece of text.""" if not pan_ids: - return dt.as_local(backup.backup_time).strftime("%c") + return dt_util.as_local(backup.backup_time).strftime("%c") identifier = ( # PAN ID @@ -78,7 +78,7 @@ def _format_backup_choice( f":{str(backup.network_info.extended_pan_id).replace(':', '')}" ).lower() - return f"{dt.as_local(backup.backup_time).strftime('%c')} ({identifier})" + return f"{dt_util.as_local(backup.backup_time).strftime('%c')} ({identifier})" async def list_serial_ports(hass: HomeAssistant) -> list[ListPortInfo]: diff --git a/tests/components/tasmota/test_sensor.py b/tests/components/tasmota/test_sensor.py index 1d9334a2657a..6a896615c734 100644 --- a/tests/components/tasmota/test_sensor.py +++ b/tests/components/tasmota/test_sensor.py @@ -19,7 +19,7 @@ from homeassistant.components.tasmota.const import DEFAULT_PREFIX from homeassistant.const import ATTR_ASSUMED_STATE, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .test_common import ( DEFAULT_CONFIG, @@ -727,7 +727,7 @@ async def test_restart_time_status_sensor_state_via_mqtt( assert not state.attributes.get(ATTR_ASSUMED_STATE) # Test polled state update - utc_now = datetime.datetime(2020, 11, 11, 8, 0, 0, tzinfo=dt.UTC) + utc_now = datetime.datetime(2020, 11, 11, 8, 0, 0, tzinfo=dt_util.UTC) hatasmota.status_sensor.datetime.now.return_value = utc_now async_fire_mqtt_message( hass, @@ -931,7 +931,8 @@ async def test_enable_status_sensor( async_fire_time_changed( hass, - dt.utcnow() + timedelta(seconds=config_entries.RELOAD_AFTER_UPDATE_DELAY + 1), + dt_util.utcnow() + + timedelta(seconds=config_entries.RELOAD_AFTER_UPDATE_DELAY + 1), ) await hass.async_block_till_done() diff --git a/tests/components/todoist/test_calendar.py b/tests/components/todoist/test_calendar.py index 7cbb73c4e922..c9f81f894044 100644 --- a/tests/components/todoist/test_calendar.py +++ b/tests/components/todoist/test_calendar.py @@ -21,7 +21,7 @@ from homeassistant.const import CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_component import async_update_entity -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from tests.typing import ClientSessionGenerator @@ -39,7 +39,9 @@ def set_time_zone(hass: HomeAssistant): @pytest.fixture(name="due") def mock_due() -> Due: """Mock a todoist Task Due date/time.""" - return Due(is_recurring=False, date=dt.now().strftime("%Y-%m-%d"), string="today") + return Due( + is_recurring=False, date=dt_util.now().strftime("%Y-%m-%d"), string="today" + ) @pytest.fixture(name="task") @@ -187,7 +189,7 @@ async def test_update_entity_for_custom_project_no_due_date_on( "due", [ Due( - date=(dt.now() + timedelta(days=3)).strftime("%Y-%m-%d"), + date=(dt_util.now() + timedelta(days=3)).strftime("%Y-%m-%d"), is_recurring=False, string="3 days from today", ) @@ -203,7 +205,9 @@ async def test_update_entity_for_calendar_with_due_date_in_the_future( assert state.state == "on" # The end time should be in the user's timezone - expected_end_time = (dt.now() + timedelta(days=3)).strftime("%Y-%m-%d 00:00:00") + expected_end_time = (dt_util.now() + timedelta(days=3)).strftime( + "%Y-%m-%d 00:00:00" + ) assert state.attributes["end_time"] == expected_end_time diff --git a/tests/components/totalconnect/test_alarm_control_panel.py b/tests/components/totalconnect/test_alarm_control_panel.py index e52da526d3d5..6161b7936102 100644 --- a/tests/components/totalconnect/test_alarm_control_panel.py +++ b/tests/components/totalconnect/test_alarm_control_panel.py @@ -32,7 +32,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_component import async_update_entity -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .common import ( LOCATION_ID, @@ -107,7 +107,7 @@ async def test_arm_home_success(hass: HomeAssistant) -> None: ) assert mock_request.call_count == 2 - async_fire_time_changed(hass, dt.utcnow() + DELAY) + async_fire_time_changed(hass, dt_util.utcnow() + DELAY) await hass.async_block_till_done() assert mock_request.call_count == 3 assert hass.states.get(ENTITY_ID).state == STATE_ALARM_ARMED_HOME @@ -163,7 +163,7 @@ async def test_arm_home_instant_success(hass: HomeAssistant) -> None: ) assert mock_request.call_count == 2 - async_fire_time_changed(hass, dt.utcnow() + DELAY) + async_fire_time_changed(hass, dt_util.utcnow() + DELAY) await hass.async_block_till_done() assert mock_request.call_count == 3 assert hass.states.get(ENTITY_ID).state == STATE_ALARM_ARMED_HOME @@ -220,7 +220,7 @@ async def test_arm_away_instant_success(hass: HomeAssistant) -> None: ) assert mock_request.call_count == 2 - async_fire_time_changed(hass, dt.utcnow() + DELAY) + async_fire_time_changed(hass, dt_util.utcnow() + DELAY) await hass.async_block_till_done() assert mock_request.call_count == 3 assert hass.states.get(ENTITY_ID).state == STATE_ALARM_ARMED_AWAY @@ -276,7 +276,7 @@ async def test_arm_away_success(hass: HomeAssistant) -> None: ) assert mock_request.call_count == 2 - async_fire_time_changed(hass, dt.utcnow() + DELAY) + async_fire_time_changed(hass, dt_util.utcnow() + DELAY) await hass.async_block_till_done() assert mock_request.call_count == 3 assert hass.states.get(ENTITY_ID).state == STATE_ALARM_ARMED_AWAY @@ -329,7 +329,7 @@ async def test_disarm_success(hass: HomeAssistant) -> None: ) assert mock_request.call_count == 2 - async_fire_time_changed(hass, dt.utcnow() + DELAY) + async_fire_time_changed(hass, dt_util.utcnow() + DELAY) await hass.async_block_till_done() assert mock_request.call_count == 3 assert hass.states.get(ENTITY_ID).state == STATE_ALARM_DISARMED @@ -386,7 +386,7 @@ async def test_arm_night_success(hass: HomeAssistant) -> None: ) assert mock_request.call_count == 2 - async_fire_time_changed(hass, dt.utcnow() + DELAY) + async_fire_time_changed(hass, dt_util.utcnow() + DELAY) await hass.async_block_till_done() assert mock_request.call_count == 3 assert hass.states.get(ENTITY_ID).state == STATE_ALARM_ARMED_NIGHT @@ -439,7 +439,7 @@ async def test_arming(hass: HomeAssistant) -> None: ) assert mock_request.call_count == 2 - async_fire_time_changed(hass, dt.utcnow() + DELAY) + async_fire_time_changed(hass, dt_util.utcnow() + DELAY) await hass.async_block_till_done() assert mock_request.call_count == 3 assert hass.states.get(ENTITY_ID).state == STATE_ALARM_ARMING @@ -460,7 +460,7 @@ async def test_disarming(hass: HomeAssistant) -> None: ) assert mock_request.call_count == 2 - async_fire_time_changed(hass, dt.utcnow() + DELAY) + async_fire_time_changed(hass, dt_util.utcnow() + DELAY) await hass.async_block_till_done() assert mock_request.call_count == 3 assert hass.states.get(ENTITY_ID).state == STATE_ALARM_DISARMING @@ -546,31 +546,31 @@ async def test_other_update_failures(hass: HomeAssistant) -> None: assert mock_request.call_count == 1 # then an error: ServiceUnavailable --> UpdateFailed - async_fire_time_changed(hass, dt.utcnow() + SCAN_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) await hass.async_block_till_done() assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE assert mock_request.call_count == 2 # works again - async_fire_time_changed(hass, dt.utcnow() + SCAN_INTERVAL * 2) + async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL * 2) await hass.async_block_till_done() assert hass.states.get(ENTITY_ID).state == STATE_ALARM_DISARMED assert mock_request.call_count == 3 # then an error: TotalConnectError --> UpdateFailed - async_fire_time_changed(hass, dt.utcnow() + SCAN_INTERVAL * 3) + async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL * 3) await hass.async_block_till_done() assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE assert mock_request.call_count == 4 # works again - async_fire_time_changed(hass, dt.utcnow() + SCAN_INTERVAL * 4) + async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL * 4) await hass.async_block_till_done() assert hass.states.get(ENTITY_ID).state == STATE_ALARM_DISARMED assert mock_request.call_count == 5 # unknown TotalConnect status via ValueError - async_fire_time_changed(hass, dt.utcnow() + SCAN_INTERVAL * 5) + async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL * 5) await hass.async_block_till_done() assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE assert mock_request.call_count == 6 diff --git a/tests/components/trafikverket_ferry/conftest.py b/tests/components/trafikverket_ferry/conftest.py index 452c351ee5df..beeca9bd9f36 100644 --- a/tests/components/trafikverket_ferry/conftest.py +++ b/tests/components/trafikverket_ferry/conftest.py @@ -10,7 +10,7 @@ from pytrafikverket.trafikverket_ferry import FerryStop from homeassistant.components.trafikverket_ferry.const import DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from . import ENTRY_CONFIG @@ -49,30 +49,32 @@ def fixture_get_ferries() -> list[FerryStop]: depart1 = FerryStop( "13", False, - datetime(dt.now().year + 1, 5, 1, 12, 0, tzinfo=dt.UTC), + datetime(dt_util.now().year + 1, 5, 1, 12, 0, tzinfo=dt_util.UTC), [""], "0", - datetime(dt.now().year, 5, 1, 12, 0, tzinfo=dt.UTC), + datetime(dt_util.now().year, 5, 1, 12, 0, tzinfo=dt_util.UTC), "Harbor 1", "Harbor 2", ) depart2 = FerryStop( "14", False, - datetime(dt.now().year + 1, 5, 1, 12, 0, tzinfo=dt.UTC) + timedelta(minutes=15), + datetime(dt_util.now().year + 1, 5, 1, 12, 0, tzinfo=dt_util.UTC) + + timedelta(minutes=15), [""], "0", - datetime(dt.now().year, 5, 1, 12, 0, tzinfo=dt.UTC), + datetime(dt_util.now().year, 5, 1, 12, 0, tzinfo=dt_util.UTC), "Harbor 1", "Harbor 2", ) depart3 = FerryStop( "15", False, - datetime(dt.now().year + 1, 5, 1, 12, 0, tzinfo=dt.UTC) + timedelta(minutes=30), + datetime(dt_util.now().year + 1, 5, 1, 12, 0, tzinfo=dt_util.UTC) + + timedelta(minutes=30), [""], "0", - datetime(dt.now().year, 5, 1, 12, 0, tzinfo=dt.UTC), + datetime(dt_util.now().year, 5, 1, 12, 0, tzinfo=dt_util.UTC), "Harbor 1", "Harbor 2", ) diff --git a/tests/components/trafikverket_ferry/test_coordinator.py b/tests/components/trafikverket_ferry/test_coordinator.py index 61c72b82a3f7..591486474d36 100644 --- a/tests/components/trafikverket_ferry/test_coordinator.py +++ b/tests/components/trafikverket_ferry/test_coordinator.py @@ -14,7 +14,7 @@ from homeassistant.components.trafikverket_ferry.coordinator import next_departu from homeassistant.config_entries import SOURCE_USER from homeassistant.const import STATE_UNAVAILABLE, WEEKDAYS from homeassistant.core import HomeAssistant -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from . import ENTRY_CONFIG @@ -50,16 +50,16 @@ async def test_coordinator( state3 = hass.states.get("sensor.harbor1_departure_time") assert state1.state == "Harbor 1" assert state2.state == "Harbor 2" - assert state3.state == str(dt.now().year + 1) + "-05-01T12:00:00+00:00" + assert state3.state == str(dt_util.now().year + 1) + "-05-01T12:00:00+00:00" mock_data.reset_mock() monkeypatch.setattr( get_ferries[0], "departure_time", - datetime(dt.now().year + 2, 5, 1, 12, 0, tzinfo=dt.UTC), + datetime(dt_util.now().year + 2, 5, 1, 12, 0, tzinfo=dt_util.UTC), ) - async_fire_time_changed(hass, dt.utcnow() + timedelta(minutes=6)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=6)) await hass.async_block_till_done() mock_data.assert_called_once() state1 = hass.states.get("sensor.harbor1_departure_from") @@ -67,11 +67,11 @@ async def test_coordinator( state3 = hass.states.get("sensor.harbor1_departure_time") assert state1.state == "Harbor 1" assert state2.state == "Harbor 2" - assert state3.state == str(dt.now().year + 2) + "-05-01T12:00:00+00:00" + assert state3.state == str(dt_util.now().year + 2) + "-05-01T12:00:00+00:00" mock_data.reset_mock() mock_data.side_effect = NoFerryFound() - async_fire_time_changed(hass, dt.utcnow() + timedelta(minutes=6)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=6)) await hass.async_block_till_done() mock_data.assert_called_once() state1 = hass.states.get("sensor.harbor1_departure_from") @@ -80,7 +80,7 @@ async def test_coordinator( mock_data.return_value = get_ferries mock_data.side_effect = None - async_fire_time_changed(hass, dt.utcnow() + timedelta(minutes=6)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=6)) await hass.async_block_till_done() # mock_data.assert_called_once() state1 = hass.states.get("sensor.harbor1_departure_from") @@ -88,7 +88,7 @@ async def test_coordinator( mock_data.reset_mock() mock_data.side_effect = InvalidAuthentication() - async_fire_time_changed(hass, dt.utcnow() + timedelta(minutes=6)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=6)) await hass.async_block_till_done() mock_data.assert_called_once() state1 = hass.states.get("sensor.harbor1_departure_from") diff --git a/tests/components/trafikverket_ferry/test_sensor.py b/tests/components/trafikverket_ferry/test_sensor.py index e0527d4b9e45..84cb856a82d2 100644 --- a/tests/components/trafikverket_ferry/test_sensor.py +++ b/tests/components/trafikverket_ferry/test_sensor.py @@ -9,7 +9,7 @@ from pytrafikverket.trafikverket_ferry import FerryStop from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed @@ -26,7 +26,7 @@ async def test_sensor( state3 = hass.states.get("sensor.harbor1_departure_time") assert state1.state == "Harbor 1" assert state2.state == "Harbor 2" - assert state3.state == str(dt.now().year + 1) + "-05-01T12:00:00+00:00" + assert state3.state == str(dt_util.now().year + 1) + "-05-01T12:00:00+00:00" assert state1.attributes["icon"] == "mdi:ferry" assert state1.attributes["other_information"] == [""] assert state2.attributes["icon"] == "mdi:ferry" @@ -39,7 +39,7 @@ async def test_sensor( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=6), + dt_util.utcnow() + timedelta(minutes=6), ) await hass.async_block_till_done() diff --git a/tests/components/unifi/test_switch.py b/tests/components/unifi/test_switch.py index 6b7ee2cd0a7f..f93abc291b8d 100644 --- a/tests/components/unifi/test_switch.py +++ b/tests/components/unifi/test_switch.py @@ -31,7 +31,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryDisabler -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .test_controller import ( CONTROLLER_HOST, @@ -1129,7 +1129,7 @@ async def test_poe_port_switches( async_fire_time_changed( hass, - dt.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), ) await hass.async_block_till_done() diff --git a/tests/components/uptimerobot/test_binary_sensor.py b/tests/components/uptimerobot/test_binary_sensor.py index 0cf0c3a6fbe9..4b27ab5ff059 100644 --- a/tests/components/uptimerobot/test_binary_sensor.py +++ b/tests/components/uptimerobot/test_binary_sensor.py @@ -11,7 +11,7 @@ from homeassistant.components.uptimerobot.const import ( ) from homeassistant.const import STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .common import ( MOCK_UPTIMEROBOT_MONITOR, @@ -45,7 +45,7 @@ async def test_unaviable_on_update_failure(hass: HomeAssistant) -> None: "pyuptimerobot.UptimeRobot.async_get_monitors", side_effect=UptimeRobotAuthenticationException, ): - async_fire_time_changed(hass, dt.utcnow() + COORDINATOR_UPDATE_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + COORDINATOR_UPDATE_INTERVAL) await hass.async_block_till_done() entity = hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY) diff --git a/tests/components/uptimerobot/test_init.py b/tests/components/uptimerobot/test_init.py index 42685880dc0c..bba5af07be3f 100644 --- a/tests/components/uptimerobot/test_init.py +++ b/tests/components/uptimerobot/test_init.py @@ -12,7 +12,7 @@ from homeassistant.components.uptimerobot.const import ( from homeassistant.const import STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .common import ( MOCK_UPTIMEROBOT_CONFIG_ENTRY_DATA, @@ -106,7 +106,7 @@ async def test_reauthentication_trigger_after_setup( "pyuptimerobot.UptimeRobot.async_get_monitors", side_effect=UptimeRobotAuthenticationException, ): - async_fire_time_changed(hass, dt.utcnow() + COORDINATOR_UPDATE_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + COORDINATOR_UPDATE_INTERVAL) await hass.async_block_till_done() flows = hass.config_entries.flow.async_progress() @@ -134,7 +134,7 @@ async def test_integration_reload(hass: HomeAssistant) -> None: return_value=mock_uptimerobot_api_response(), ): assert await hass.config_entries.async_reload(mock_entry.entry_id) - async_fire_time_changed(hass, dt.utcnow() + COORDINATOR_UPDATE_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + COORDINATOR_UPDATE_INTERVAL) await hass.async_block_till_done() entry = hass.config_entries.async_get_entry(mock_entry.entry_id) @@ -152,7 +152,7 @@ async def test_update_errors( "pyuptimerobot.UptimeRobot.async_get_monitors", side_effect=UptimeRobotException, ): - async_fire_time_changed(hass, dt.utcnow() + COORDINATOR_UPDATE_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + COORDINATOR_UPDATE_INTERVAL) await hass.async_block_till_done() assert ( hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY).state @@ -163,7 +163,7 @@ async def test_update_errors( "pyuptimerobot.UptimeRobot.async_get_monitors", return_value=mock_uptimerobot_api_response(), ): - async_fire_time_changed(hass, dt.utcnow() + COORDINATOR_UPDATE_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + COORDINATOR_UPDATE_INTERVAL) await hass.async_block_till_done() assert hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY).state == STATE_ON @@ -171,7 +171,7 @@ async def test_update_errors( "pyuptimerobot.UptimeRobot.async_get_monitors", return_value=mock_uptimerobot_api_response(key=MockApiResponseKey.ERROR), ): - async_fire_time_changed(hass, dt.utcnow() + COORDINATOR_UPDATE_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + COORDINATOR_UPDATE_INTERVAL) await hass.async_block_till_done() assert ( hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY).state @@ -201,7 +201,7 @@ async def test_device_management(hass: HomeAssistant) -> None: data=[MOCK_UPTIMEROBOT_MONITOR, {**MOCK_UPTIMEROBOT_MONITOR, "id": 12345}] ), ): - async_fire_time_changed(hass, dt.utcnow() + COORDINATOR_UPDATE_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + COORDINATOR_UPDATE_INTERVAL) await hass.async_block_till_done() devices = dr.async_entries_for_config_entry(dev_reg, mock_entry.entry_id) @@ -218,7 +218,7 @@ async def test_device_management(hass: HomeAssistant) -> None: "pyuptimerobot.UptimeRobot.async_get_monitors", return_value=mock_uptimerobot_api_response(), ): - async_fire_time_changed(hass, dt.utcnow() + COORDINATOR_UPDATE_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + COORDINATOR_UPDATE_INTERVAL) await hass.async_block_till_done() await hass.async_block_till_done() diff --git a/tests/components/uptimerobot/test_sensor.py b/tests/components/uptimerobot/test_sensor.py index 68b64f70e5a2..110ea07c202b 100644 --- a/tests/components/uptimerobot/test_sensor.py +++ b/tests/components/uptimerobot/test_sensor.py @@ -8,7 +8,7 @@ from homeassistant.components.sensor import SensorDeviceClass from homeassistant.components.uptimerobot.const import COORDINATOR_UPDATE_INTERVAL from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .common import ( MOCK_UPTIMEROBOT_MONITOR, @@ -52,7 +52,7 @@ async def test_unaviable_on_update_failure(hass: HomeAssistant) -> None: "pyuptimerobot.UptimeRobot.async_get_monitors", side_effect=UptimeRobotAuthenticationException, ): - async_fire_time_changed(hass, dt.utcnow() + COORDINATOR_UPDATE_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + COORDINATOR_UPDATE_INTERVAL) await hass.async_block_till_done() entity = hass.states.get(UPTIMEROBOT_SENSOR_TEST_ENTITY) diff --git a/tests/components/vallox/test_sensor.py b/tests/components/vallox/test_sensor.py index 6cdf76d1227f..c086719cbc32 100644 --- a/tests/components/vallox/test_sensor.py +++ b/tests/components/vallox/test_sensor.py @@ -6,7 +6,7 @@ from unittest.mock import patch import pytest from homeassistant.core import HomeAssistant -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .conftest import patch_metrics @@ -42,7 +42,7 @@ def _sensor_to_datetime(sensor): def _now_at_13(): - return dt.now().timetz().replace(hour=13, minute=0, second=0, microsecond=0) + return dt_util.now().timetz().replace(hour=13, minute=0, second=0, microsecond=0) async def test_remaining_filter_returns_timestamp( @@ -52,7 +52,7 @@ async def test_remaining_filter_returns_timestamp( # Act with patch( "homeassistant.components.vallox._api_get_next_filter_change_date", - return_value=dt.now().date(), + return_value=dt_util.now().date(), ), patch_metrics(metrics={}): await hass.config_entries.async_setup(mock_entry.entry_id) await hass.async_block_till_done() @@ -94,7 +94,7 @@ async def test_remaining_time_for_filter_in_the_future( """Test remaining time for filter when Vallox returns a date in the future.""" # Arrange remaining_days = 112 - mocked_filter_end_date = dt.now().date() + timedelta(days=remaining_days) + mocked_filter_end_date = dt_util.now().date() + timedelta(days=remaining_days) # Act with patch( @@ -118,7 +118,7 @@ async def test_remaining_time_for_filter_today( """Test remaining time for filter when Vallox returns today.""" # Arrange remaining_days = 0 - mocked_filter_end_date = dt.now().date() + timedelta(days=remaining_days) + mocked_filter_end_date = dt_util.now().date() + timedelta(days=remaining_days) # Act with patch( @@ -142,7 +142,7 @@ async def test_remaining_time_for_filter_in_the_past( """Test remaining time for filter when Vallox returns a date in the past.""" # Arrange remaining_days = -3 - mocked_filter_end_date = dt.now().date() + timedelta(days=remaining_days) + mocked_filter_end_date = dt_util.now().date() + timedelta(days=remaining_days) # Act with patch( diff --git a/tests/components/version/common.py b/tests/components/version/common.py index a6b554d780df..3e3ae6c39709 100644 --- a/tests/components/version/common.py +++ b/tests/components/version/common.py @@ -14,7 +14,7 @@ from homeassistant.components.version.const import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed @@ -47,7 +47,9 @@ async def mock_get_version_update( return_value=(version, data), side_effect=side_effect, ): - async_fire_time_changed(hass, dt.utcnow() + UPDATE_COORDINATOR_UPDATE_INTERVAL) + async_fire_time_changed( + hass, dt_util.utcnow() + UPDATE_COORDINATOR_UPDATE_INTERVAL + ) await hass.async_block_till_done() diff --git a/tests/components/version/test_config_flow.py b/tests/components/version/test_config_flow.py index f110dc857480..8de4afff92e1 100644 --- a/tests/components/version/test_config_flow.py +++ b/tests/components/version/test_config_flow.py @@ -20,7 +20,7 @@ from homeassistant.components.version.const import ( from homeassistant.const import CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .common import MOCK_VERSION, MOCK_VERSION_DATA, setup_version_integration @@ -37,7 +37,9 @@ async def test_reload_config_entry(hass: HomeAssistant) -> None: return_value=(MOCK_VERSION, MOCK_VERSION_DATA), ): assert await hass.config_entries.async_reload(config_entry.entry_id) - async_fire_time_changed(hass, dt.utcnow() + UPDATE_COORDINATOR_UPDATE_INTERVAL) + async_fire_time_changed( + hass, dt_util.utcnow() + UPDATE_COORDINATOR_UPDATE_INTERVAL + ) await hass.async_block_till_done() entry = hass.config_entries.async_get_entry(config_entry.entry_id) diff --git a/tests/components/webostv/test_media_player.py b/tests/components/webostv/test_media_player.py index afc7bca513c2..b400951f61f6 100644 --- a/tests/components/webostv/test_media_player.py +++ b/tests/components/webostv/test_media_player.py @@ -63,7 +63,7 @@ from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from . import setup_webostv from .const import CHANNEL_2, ENTITY_ID, TV_NAME @@ -479,7 +479,7 @@ async def test_client_disconnected(hass: HomeAssistant, client, monkeypatch) -> monkeypatch.setattr(client, "is_connected", Mock(return_value=False)) monkeypatch.setattr(client, "connect", Mock(side_effect=asyncio.TimeoutError)) - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=20)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=20)) await hass.async_block_till_done() @@ -804,7 +804,7 @@ async def test_reauth_reconnect(hass: HomeAssistant, client, monkeypatch) -> Non assert entry.state == ConfigEntryState.LOADED - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=20)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=20)) await hass.async_block_till_done() assert entry.state == ConfigEntryState.LOADED diff --git a/tests/components/wemo/test_init.py b/tests/components/wemo/test_init.py index 7e3e0b2dce8e..efdcb5424d14 100644 --- a/tests/components/wemo/test_init.py +++ b/tests/components/wemo/test_init.py @@ -9,7 +9,7 @@ from homeassistant.components.wemo.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .conftest import ( MOCK_FIRMWARE_VERSION, @@ -164,7 +164,7 @@ async def test_discovery(hass: HomeAssistant, pywemo_registry) -> None: # Test that discovery runs periodically and the async_dispatcher_send code works. async_fire_time_changed( hass, - dt.utcnow() + dt_util.utcnow() + timedelta(seconds=WemoDiscovery.ADDITIONAL_SECONDS_BETWEEN_SCANS + 1), ) await hass.async_block_till_done() diff --git a/tests/components/youtube/test_sensor.py b/tests/components/youtube/test_sensor.py index b70b9dc2bd9f..f4dbd9cc3a52 100644 --- a/tests/components/youtube/test_sensor.py +++ b/tests/components/youtube/test_sensor.py @@ -7,7 +7,7 @@ from google.auth.exceptions import RefreshError from homeassistant import config_entries from homeassistant.components.youtube import DOMAIN from homeassistant.core import HomeAssistant -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from ...common import async_fire_time_changed from .conftest import TOKEN, ComponentSetup @@ -43,7 +43,7 @@ async def test_sensor_reauth_trigger( await setup_integration() with patch(TOKEN, side_effect=RefreshError): - future = dt.utcnow() + timedelta(minutes=15) + future = dt_util.utcnow() + timedelta(minutes=15) async_fire_time_changed(hass, future) await hass.async_block_till_done() From 5aadd7ffc9914b2e055333371f92e135a70f8f19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Tue, 30 May 2023 00:01:00 +0300 Subject: [PATCH 660/822] Import `util.dt` as `dt_util` in `components/[g-i]*` (#93759) --- homeassistant/components/gdacs/sensor.py | 8 +++++--- homeassistant/components/geonetnz_quakes/sensor.py | 8 +++++--- homeassistant/components/geonetnz_volcano/sensor.py | 6 +++--- homeassistant/components/google/api.py | 10 +++++++--- homeassistant/components/google_assistant/trait.py | 4 ++-- homeassistant/components/google_wifi/sensor.py | 4 ++-- homeassistant/components/growatt_server/sensor.py | 8 ++++---- homeassistant/components/here_travel_time/__init__.py | 6 +++--- .../components/here_travel_time/coordinator.py | 4 ++-- homeassistant/components/hydrawise/sensor.py | 6 +++--- homeassistant/components/iotawatt/sensor.py | 4 ++-- tests/components/github/test_sensor.py | 4 ++-- tests/components/google_assistant/test_helpers.py | 6 +++--- tests/components/hyperion/test_light.py | 4 ++-- tests/components/hyperion/test_switch.py | 4 ++-- tests/components/image_upload/test_init.py | 4 ++-- 16 files changed, 49 insertions(+), 41 deletions(-) diff --git a/homeassistant/components/gdacs/sensor.py b/homeassistant/components/gdacs/sensor.py index 6563e26368ab..e1535037d353 100644 --- a/homeassistant/components/gdacs/sensor.py +++ b/homeassistant/components/gdacs/sensor.py @@ -8,7 +8,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .const import DEFAULT_ICON, DOMAIN, FEED @@ -93,10 +93,12 @@ class GdacsSensor(SensorEntity): """Update the internal state from the provided information.""" self._status = status_info.status self._last_update = ( - dt.as_utc(status_info.last_update) if status_info.last_update else None + dt_util.as_utc(status_info.last_update) if status_info.last_update else None ) if status_info.last_update_successful: - self._last_update_successful = dt.as_utc(status_info.last_update_successful) + self._last_update_successful = dt_util.as_utc( + status_info.last_update_successful + ) else: self._last_update_successful = None self._last_timestamp = status_info.last_timestamp diff --git a/homeassistant/components/geonetnz_quakes/sensor.py b/homeassistant/components/geonetnz_quakes/sensor.py index 8fb2ff8535b4..e69ba6eb005c 100644 --- a/homeassistant/components/geonetnz_quakes/sensor.py +++ b/homeassistant/components/geonetnz_quakes/sensor.py @@ -8,7 +8,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .const import DOMAIN, FEED @@ -94,10 +94,12 @@ class GeonetnzQuakesSensor(SensorEntity): """Update the internal state from the provided information.""" self._status = status_info.status self._last_update = ( - dt.as_utc(status_info.last_update) if status_info.last_update else None + dt_util.as_utc(status_info.last_update) if status_info.last_update else None ) if status_info.last_update_successful: - self._last_update_successful = dt.as_utc(status_info.last_update_successful) + self._last_update_successful = dt_util.as_utc( + status_info.last_update_successful + ) else: self._last_update_successful = None self._last_timestamp = status_info.last_timestamp diff --git a/homeassistant/components/geonetnz_volcano/sensor.py b/homeassistant/components/geonetnz_volcano/sensor.py index 33a879eeb255..583b75a24eb3 100644 --- a/homeassistant/components/geonetnz_volcano/sensor.py +++ b/homeassistant/components/geonetnz_volcano/sensor.py @@ -9,7 +9,7 @@ from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, UnitOfLength from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import DistanceConverter from .const import ( @@ -124,9 +124,9 @@ class GeonetnzVolcanoSensor(SensorEntity): self._alert_level = feed_entry.alert_level self._activity = feed_entry.activity self._hazards = feed_entry.hazards - self._feed_last_update = dt.as_utc(last_update) if last_update else None + self._feed_last_update = dt_util.as_utc(last_update) if last_update else None self._feed_last_update_successful = ( - dt.as_utc(last_update_successful) if last_update_successful else None + dt_util.as_utc(last_update_successful) if last_update_successful else None ) @property diff --git a/homeassistant/components/google/api.py b/homeassistant/components/google/api.py index 47aa32dcd11c..a3a5b7246b61 100644 --- a/homeassistant/components/google/api.py +++ b/homeassistant/components/google/api.py @@ -24,7 +24,7 @@ from homeassistant.helpers.event import ( async_track_point_in_utc_time, async_track_time_interval, ) -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .const import ( CONF_CALENDAR_ACCESS, @@ -51,7 +51,9 @@ class DeviceAuth(AuthImplementation): async def async_resolve_external_data(self, external_data: Any) -> dict: """Resolve a Google API Credentials object to Home Assistant token.""" creds: Credentials = external_data[DEVICE_AUTH_CREDS] - delta = creds.token_expiry.replace(tzinfo=datetime.timezone.utc) - dt.utcnow() + delta = ( + creds.token_expiry.replace(tzinfo=datetime.timezone.utc) - dt_util.utcnow() + ) _LOGGER.debug( "Token expires at %s (in %s)", creds.token_expiry, delta.total_seconds() ) @@ -108,7 +110,9 @@ class DeviceFlow: def async_start_exchange(self) -> None: """Start the device auth exchange flow polling.""" _LOGGER.debug("Starting exchange flow") - max_timeout = dt.utcnow() + datetime.timedelta(seconds=EXCHANGE_TIMEOUT_SECONDS) + max_timeout = dt_util.utcnow() + datetime.timedelta( + seconds=EXCHANGE_TIMEOUT_SECONDS + ) # For some reason, oauth.step1_get_device_and_user_codes() returns a datetime # object without tzinfo. For the comparison below to work, it needs one. user_code_expiry = self._device_flow_info.user_code_expiry.replace( diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index a075a251dbaf..6f7dbd2c3b02 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -68,7 +68,7 @@ from homeassistant.const import ( ) from homeassistant.core import DOMAIN as HA_DOMAIN from homeassistant.helpers.network import get_url -from homeassistant.util import color as color_util, dt +from homeassistant.util import color as color_util, dt as dt_util from homeassistant.util.percentage import ( ordered_list_item_to_percentage, percentage_to_ordered_list_item, @@ -2218,7 +2218,7 @@ class TransportControlTrait(_Trait): rel_position = params["relativePositionMs"] / 1000 seconds_since = 0 # Default to 0 seconds if self.state.state == STATE_PLAYING: - now = dt.utcnow() + now = dt_util.utcnow() upd_at = self.state.attributes.get( media_player.ATTR_MEDIA_POSITION_UPDATED_AT, now ) diff --git a/homeassistant/components/google_wifi/sensor.py b/homeassistant/components/google_wifi/sensor.py index 9fe264219ece..6bf552b824be 100644 --- a/homeassistant/components/google_wifi/sensor.py +++ b/homeassistant/components/google_wifi/sensor.py @@ -23,7 +23,7 @@ from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import Throttle, dt +from homeassistant.util import Throttle, dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -212,7 +212,7 @@ class GoogleWifiAPI: elif attr_key == ATTR_UPTIME: sensor_value = round(sensor_value / (3600 * 24), 2) elif attr_key == ATTR_LAST_RESTART: - last_restart = dt.now() - timedelta(seconds=sensor_value) + last_restart = dt_util.now() - timedelta(seconds=sensor_value) sensor_value = last_restart.strftime("%Y-%m-%d %H:%M:%S") elif attr_key == ATTR_STATUS: if sensor_value: diff --git a/homeassistant/components/growatt_server/sensor.py b/homeassistant/components/growatt_server/sensor.py index f4150068399f..a25f8f0342f2 100644 --- a/homeassistant/components/growatt_server/sensor.py +++ b/homeassistant/components/growatt_server/sensor.py @@ -13,7 +13,7 @@ from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_URL, CONF_USERNAM from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import Throttle, dt +from homeassistant.util import Throttle, dt as dt_util from .const import ( CONF_PLANT_ID, @@ -234,10 +234,10 @@ class GrowattData: sorted_keys = sorted(mix_chart_entries) # Create datetime from the latest entry - date_now = dt.now().date() - last_updated_time = dt.parse_time(str(sorted_keys[-1])) + date_now = dt_util.now().date() + last_updated_time = dt_util.parse_time(str(sorted_keys[-1])) mix_detail["lastdataupdate"] = datetime.datetime.combine( - date_now, last_updated_time, dt.DEFAULT_TIME_ZONE + date_now, last_updated_time, dt_util.DEFAULT_TIME_ZONE ) # Dashboard data is largely inaccurate for mix system but it is the only diff --git a/homeassistant/components/here_travel_time/__init__.py b/homeassistant/components/here_travel_time/__init__.py index 2ce91946f86e..1c728bcc12c3 100644 --- a/homeassistant/components/here_travel_time/__init__.py +++ b/homeassistant/components/here_travel_time/__init__.py @@ -4,7 +4,7 @@ from __future__ import annotations from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_MODE, Platform from homeassistant.core import HomeAssistant -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .const import ( CONF_ARRIVAL_TIME, @@ -32,8 +32,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b """Set up HERE Travel Time from a config entry.""" api_key = config_entry.data[CONF_API_KEY] - arrival = dt.parse_time(config_entry.options.get(CONF_ARRIVAL_TIME, "")) - departure = dt.parse_time(config_entry.options.get(CONF_DEPARTURE_TIME, "")) + arrival = dt_util.parse_time(config_entry.options.get(CONF_ARRIVAL_TIME, "")) + departure = dt_util.parse_time(config_entry.options.get(CONF_DEPARTURE_TIME, "")) here_travel_time_config = HERETravelTimeConfig( destination_latitude=config_entry.data.get(CONF_DESTINATION_LATITUDE), diff --git a/homeassistant/components/here_travel_time/coordinator.py b/homeassistant/components/here_travel_time/coordinator.py index ae8bfc34a42f..dbb17b58336e 100644 --- a/homeassistant/components/here_travel_time/coordinator.py +++ b/homeassistant/components/here_travel_time/coordinator.py @@ -29,7 +29,7 @@ from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.location import find_coordinates from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import DistanceConverter from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, ROUTE_MODE_FASTEST @@ -336,7 +336,7 @@ def build_hass_attribution(sections: list[dict[str, Any]]) -> str | None: def next_datetime(simple_time: time) -> datetime: """Take a time like 08:00:00 and combine it with the current date.""" - combined = datetime.combine(dt.start_of_local_day(), simple_time) + combined = datetime.combine(dt_util.start_of_local_day(), simple_time) if combined < datetime.now(): combined = combined + timedelta(days=1) return combined diff --git a/homeassistant/components/hydrawise/sensor.py b/homeassistant/components/hydrawise/sensor.py index f47f404cfa0a..d1334143375d 100644 --- a/homeassistant/components/hydrawise/sensor.py +++ b/homeassistant/components/hydrawise/sensor.py @@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant, callback 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 +from homeassistant.util import dt as dt_util from .const import DOMAIN, LOGGER from .coordinator import HydrawiseDataUpdateCoordinator @@ -86,7 +86,7 @@ class HydrawiseSensor(HydrawiseEntity, SensorEntity): else: # _sensor_type == 'next_cycle' next_cycle = min(relay_data["time"], TWO_YEAR_SECONDS) LOGGER.debug("New cycle time: %s", next_cycle) - self._attr_native_value = dt.utc_from_timestamp( - dt.as_timestamp(dt.now()) + next_cycle + self._attr_native_value = dt_util.utc_from_timestamp( + dt_util.as_timestamp(dt_util.now()) + next_cycle ) super()._handle_coordinator_update() diff --git a/homeassistant/components/iotawatt/sensor.py b/homeassistant/components/iotawatt/sensor.py index 849a2055ce32..b616c7e4ae9b 100644 --- a/homeassistant/components/iotawatt/sensor.py +++ b/homeassistant/components/iotawatt/sensor.py @@ -28,7 +28,7 @@ from homeassistant.helpers import device_registry as dr, entity, entity_registry from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .const import DOMAIN, VOLT_AMPERE_REACTIVE, VOLT_AMPERE_REACTIVE_HOURS from .coordinator import IotawattUpdater @@ -203,7 +203,7 @@ class IotaWattSensor(CoordinatorEntity[IotawattUpdater], SensorEntity): return if (begin := self._sensor_data.getBegin()) and ( - last_reset := dt.parse_datetime(begin) + last_reset := dt_util.parse_datetime(begin) ): self._attr_last_reset = last_reset diff --git a/tests/components/github/test_sensor.py b/tests/components/github/test_sensor.py index 60574c5b60c9..f81f59e88c97 100644 --- a/tests/components/github/test_sensor.py +++ b/tests/components/github/test_sensor.py @@ -5,7 +5,7 @@ import pytest from homeassistant.components.github.const import DOMAIN, FALLBACK_UPDATE_INTERVAL from homeassistant.core import HomeAssistant -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .common import TEST_REPOSITORY @@ -42,7 +42,7 @@ async def test_sensor_updates_with_empty_release_array( headers=headers, ) - async_fire_time_changed(hass, dt.utcnow() + FALLBACK_UPDATE_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + FALLBACK_UPDATE_INTERVAL) await hass.async_block_till_done() new_state = hass.states.get(TEST_SENSOR_ENTITY) diff --git a/tests/components/google_assistant/test_helpers.py b/tests/components/google_assistant/test_helpers.py index 793db076c79c..17df677110b2 100644 --- a/tests/components/google_assistant/test_helpers.py +++ b/tests/components/google_assistant/test_helpers.py @@ -17,7 +17,7 @@ from homeassistant.components.google_assistant.const import ( from homeassistant.config import async_process_ha_core_config from homeassistant.core import HomeAssistant, State from homeassistant.setup import async_setup_component -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from . import MockConfig @@ -128,7 +128,7 @@ async def test_config_local_sdk( assert config.is_local_connected is True with patch( "homeassistant.components.google_assistant.helpers.utcnow", - return_value=dt.utcnow() + timedelta(seconds=90), + return_value=dt_util.utcnow() + timedelta(seconds=90), ): assert config.is_local_connected is False @@ -255,7 +255,7 @@ async def test_agent_user_id_storage( } async def _check_after_delay(data): - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=2)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=2)) await hass.async_block_till_done() assert ( diff --git a/tests/components/hyperion/test_light.py b/tests/components/hyperion/test_light.py index e7bde1661e95..33f6acf995f7 100644 --- a/tests/components/hyperion/test_light.py +++ b/tests/components/hyperion/test_light.py @@ -44,7 +44,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.util import dt +from homeassistant.util import dt as dt_util import homeassistant.util.color as color_util from . import ( @@ -1370,7 +1370,7 @@ async def test_lights_can_be_enabled(hass: HomeAssistant) -> None: async_fire_time_changed( hass, - dt.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), ) await hass.async_block_till_done() diff --git a/tests/components/hyperion/test_switch.py b/tests/components/hyperion/test_switch.py index cd1dbdcda5be..49338c72c5d0 100644 --- a/tests/components/hyperion/test_switch.py +++ b/tests/components/hyperion/test_switch.py @@ -22,7 +22,7 @@ from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.util import dt, slugify +from homeassistant.util import dt as dt_util, slugify from . import ( TEST_CONFIG_ENTRY_ID, @@ -215,7 +215,7 @@ async def test_switches_can_be_enabled(hass: HomeAssistant) -> None: async_fire_time_changed( hass, - dt.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), ) await hass.async_block_till_done() diff --git a/tests/components/image_upload/test_init.py b/tests/components/image_upload/test_init.py index 0764b34ee606..486f98e92c23 100644 --- a/tests/components/image_upload/test_init.py +++ b/tests/components/image_upload/test_init.py @@ -8,7 +8,7 @@ from aiohttp import ClientSession, ClientWebSocketResponse from homeassistant.components.websocket_api import const as ws_const from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from homeassistant.util import dt as util_dt +from homeassistant.util import dt as dt_util from . import TEST_IMAGE @@ -21,7 +21,7 @@ async def test_upload_image( hass_ws_client: WebSocketGenerator, ) -> None: """Test we can upload an image.""" - now = util_dt.utcnow() + now = dt_util.utcnow() with tempfile.TemporaryDirectory() as tempdir, patch.object( hass.config, "path", return_value=tempdir From ced67e5800d119edd92f31ff4dacd064074361da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Tue, 30 May 2023 00:02:06 +0300 Subject: [PATCH 661/822] Import `util.dt` as `dt_util` in `components/[a-d]*` (#93756) --- homeassistant/components/alexa/auth.py | 8 ++--- homeassistant/components/backup/manager.py | 4 +-- homeassistant/components/broadlink/remote.py | 14 ++++---- homeassistant/components/broadlink/updater.py | 6 ++-- homeassistant/components/caldav/calendar.py | 12 +++---- homeassistant/components/calendar/__init__.py | 32 +++++++++-------- .../components/cert_expiry/helper.py | 4 +-- homeassistant/components/demo/mailbox.py | 6 ++-- .../advantage_air/test_binary_sensor.py | 6 ++-- tests/components/advantage_air/test_sensor.py | 4 +-- tests/components/anova/test_sensor.py | 4 +-- tests/components/broadlink/test_heartbeat.py | 10 +++--- tests/components/broadlink/test_sensors.py | 6 ++-- tests/components/caldav/test_calendar.py | 36 ++++++++++++------- tests/components/cert_expiry/helpers.py | 4 +-- tests/components/cloud/test_repairs.py | 6 ++-- tests/components/command_line/test_sensor.py | 6 ++-- tests/components/deconz/test_sensor.py | 4 +-- tests/components/demo/test_vacuum.py | 4 +-- .../devolo_home_network/test_binary_sensor.py | 6 ++-- .../test_device_tracker.py | 10 +++--- .../devolo_home_network/test_sensor.py | 14 ++++---- .../devolo_home_network/test_switch.py | 16 ++++----- tests/components/dnsip/test_sensor.py | 4 +-- 24 files changed, 121 insertions(+), 105 deletions(-) diff --git a/homeassistant/components/alexa/auth.py b/homeassistant/components/alexa/auth.py index 2dbda64568f0..86c038e2da87 100644 --- a/homeassistant/components/alexa/auth.py +++ b/homeassistant/components/alexa/auth.py @@ -12,7 +12,7 @@ from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.core import callback from homeassistant.helpers import aiohttp_client from homeassistant.helpers.storage import Store -from homeassistant.util import dt +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -95,12 +95,12 @@ class Auth: if not self._prefs[STORAGE_ACCESS_TOKEN]: return False - expire_time = dt.parse_datetime(self._prefs[STORAGE_EXPIRE_TIME]) + expire_time = dt_util.parse_datetime(self._prefs[STORAGE_EXPIRE_TIME]) preemptive_expire_time = expire_time - timedelta( seconds=PREEMPTIVE_REFRESH_TTL_IN_SECONDS ) - return dt.utcnow() < preemptive_expire_time + return dt_util.utcnow() < preemptive_expire_time async def _async_request_new_token(self, lwa_params): try: @@ -130,7 +130,7 @@ class Auth: access_token = response_json["access_token"] refresh_token = response_json["refresh_token"] expires_in = response_json["expires_in"] - expire_time = dt.utcnow() + timedelta(seconds=expires_in) + expire_time = dt_util.utcnow() + timedelta(seconds=expires_in) await self._async_update_preferences( access_token, refresh_token, expire_time.isoformat() diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 1f8b70f4d357..fe0d494a6509 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -18,7 +18,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import integration_platform from homeassistant.helpers.json import save_json -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from homeassistant.util.json import json_loads_object from .const import DOMAIN, EXCLUDE_FROM_BACKUP, LOGGER @@ -176,7 +176,7 @@ class BackupManager: raise result backup_name = f"Core {HAVERSION}" - date_str = dt.now().isoformat() + date_str = dt_util.now().isoformat() slug = _generate_slug(date_str, backup_name) backup_data = { diff --git a/homeassistant/components/broadlink/remote.py b/homeassistant/components/broadlink/remote.py index 4bbb3fe15136..c116a1bb6357 100644 --- a/homeassistant/components/broadlink/remote.py +++ b/homeassistant/components/broadlink/remote.py @@ -39,7 +39,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.storage import Store -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .const import DOMAIN from .entity import BroadlinkEntity @@ -330,8 +330,8 @@ class BroadlinkRemote(BroadlinkEntity, RemoteEntity, RestoreEntity): ) try: - start_time = dt.utcnow() - while (dt.utcnow() - start_time) < LEARNING_TIMEOUT: + start_time = dt_util.utcnow() + while (dt_util.utcnow() - start_time) < LEARNING_TIMEOUT: await asyncio.sleep(1) try: code = await device.async_request(device.api.check_data) @@ -368,8 +368,8 @@ class BroadlinkRemote(BroadlinkEntity, RemoteEntity, RestoreEntity): ) try: - start_time = dt.utcnow() - while (dt.utcnow() - start_time) < LEARNING_TIMEOUT: + start_time = dt_util.utcnow() + while (dt_util.utcnow() - start_time) < LEARNING_TIMEOUT: await asyncio.sleep(1) found = await device.async_request(device.api.check_frequency) if found: @@ -403,8 +403,8 @@ class BroadlinkRemote(BroadlinkEntity, RemoteEntity, RestoreEntity): ) try: - start_time = dt.utcnow() - while (dt.utcnow() - start_time) < LEARNING_TIMEOUT: + start_time = dt_util.utcnow() + while (dt_util.utcnow() - start_time) < LEARNING_TIMEOUT: await asyncio.sleep(1) try: code = await device.async_request(device.api.check_data) diff --git a/homeassistant/components/broadlink/updater.py b/homeassistant/components/broadlink/updater.py index f3837c732633..da8461bf90fc 100644 --- a/homeassistant/components/broadlink/updater.py +++ b/homeassistant/components/broadlink/updater.py @@ -6,7 +6,7 @@ import logging from broadlink.exceptions import AuthorizationError, BroadlinkException from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util import dt +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -64,7 +64,7 @@ class BroadlinkUpdateManager(ABC): except (BroadlinkException, OSError) as err: if self.available and ( - dt.utcnow() - self.last_update > self.SCAN_INTERVAL * 3 + dt_util.utcnow() - self.last_update > self.SCAN_INTERVAL * 3 or isinstance(err, (AuthorizationError, OSError)) ): self.available = False @@ -84,7 +84,7 @@ class BroadlinkUpdateManager(ABC): self.device.api.host[0], ) self.available = True - self.last_update = dt.utcnow() + self.last_update = dt_util.utcnow() return data @abstractmethod diff --git a/homeassistant/components/caldav/calendar.py b/homeassistant/components/caldav/calendar.py index edd5cc471b89..e4892ae0383e 100644 --- a/homeassistant/components/caldav/calendar.py +++ b/homeassistant/components/caldav/calendar.py @@ -29,7 +29,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import Throttle, dt +from homeassistant.util import Throttle, dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -204,8 +204,8 @@ class WebDavCalendarData: @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """Get the latest data.""" - start_of_today = dt.start_of_local_day() - start_of_tomorrow = dt.start_of_local_day() + timedelta(days=self.days) + start_of_today = dt_util.start_of_local_day() + start_of_tomorrow = dt_util.start_of_local_day() + timedelta(days=self.days) # We have to retrieve the results for the whole day as the server # won't return events that have already started @@ -312,7 +312,7 @@ class WebDavCalendarData: @staticmethod def is_over(vevent): """Return if the event is over.""" - return dt.now() >= WebDavCalendarData.to_datetime( + return dt_util.now() >= WebDavCalendarData.to_datetime( WebDavCalendarData.get_end_date(vevent) ) @@ -321,7 +321,7 @@ class WebDavCalendarData: """Return a datetime.""" if isinstance(obj, datetime): return WebDavCalendarData.to_local(obj) - return datetime.combine(obj, time.min).replace(tzinfo=dt.DEFAULT_TIME_ZONE) + return datetime.combine(obj, time.min).replace(tzinfo=dt_util.DEFAULT_TIME_ZONE) @staticmethod def to_local(obj: datetime | date) -> datetime | date: @@ -332,7 +332,7 @@ class WebDavCalendarData: used by the caldav client and dateutil so the datetime can be copied. """ if isinstance(obj, datetime): - return dt.as_local(obj) + return dt_util.as_local(obj) return obj @staticmethod diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index 0f047bf37585..2cb807169eac 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -31,7 +31,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.template import DATE_STR_FORMAT from homeassistant.helpers.typing import ConfigType -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .const import ( CONF_EVENT, @@ -117,7 +117,7 @@ def _as_local_timezone(*keys: Any) -> Callable[[dict[str, Any]], dict[str, Any]] """Convert all keys that are datetime values to local timezone.""" for k in keys: if (value := obj.get(k)) and isinstance(value, datetime.datetime): - obj[k] = dt.as_local(value) + obj[k] = dt_util.as_local(value) return obj return validate @@ -294,14 +294,14 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: def get_date(date: dict[str, Any]) -> datetime.datetime: """Get the dateTime from date or dateTime as a local.""" if "date" in date: - parsed_date = dt.parse_date(date["date"]) + parsed_date = dt_util.parse_date(date["date"]) assert parsed_date - return dt.start_of_local_day( + return dt_util.start_of_local_day( datetime.datetime.combine(parsed_date, datetime.time.min) ) - parsed_datetime = dt.parse_datetime(date["dateTime"]) + parsed_datetime = dt_util.parse_datetime(date["dateTime"]) assert parsed_datetime - return dt.as_local(parsed_datetime) + return dt_util.as_local(parsed_datetime) @dataclasses.dataclass @@ -380,7 +380,7 @@ def _api_event_dict_factory(obj: Iterable[tuple[str, Any]]) -> dict[str, Any]: result: dict[str, Any] = {} for name, value in obj: if isinstance(value, datetime.datetime): - result[name] = {"dateTime": dt.as_local(value).isoformat()} + result[name] = {"dateTime": dt_util.as_local(value).isoformat()} elif isinstance(value, datetime.date): result[name] = {"date": value.isoformat()} else: @@ -393,14 +393,14 @@ def _get_datetime_local( ) -> datetime.datetime: """Convert a calendar event date/datetime to a datetime if needed.""" if isinstance(dt_or_d, datetime.datetime): - return dt.as_local(dt_or_d) - return dt.start_of_local_day(dt_or_d) + return dt_util.as_local(dt_or_d) + return dt_util.start_of_local_day(dt_or_d) def _get_api_date(dt_or_d: datetime.datetime | datetime.date) -> dict[str, str]: """Convert a calendar event date/datetime to a datetime if needed.""" if isinstance(dt_or_d, datetime.datetime): - return {"dateTime": dt.as_local(dt_or_d).isoformat()} + return {"dateTime": dt_util.as_local(dt_or_d).isoformat()} return {"date": dt_or_d.isoformat()} @@ -433,7 +433,7 @@ def is_offset_reached( """Have we reached the offset time specified in the event title.""" if offset_time == datetime.timedelta(): return False - return start + offset_time <= dt.now(start.tzinfo) + return start + offset_time <= dt_util.now(start.tzinfo) class CalendarEntity(Entity): @@ -467,7 +467,7 @@ class CalendarEntity(Entity): if (event := self.event) is None: return STATE_OFF - now = dt.now() + now = dt_util.now() if event.start_datetime_local <= now < event.end_datetime_local: return STATE_ON @@ -529,8 +529,8 @@ class CalendarEventView(http.HomeAssistantView): if start is None or end is None: return web.Response(status=HTTPStatus.BAD_REQUEST) try: - start_date = dt.parse_datetime(start) - end_date = dt.parse_datetime(end) + start_date = dt_util.parse_datetime(start) + end_date = dt_util.parse_datetime(end) except (ValueError, AttributeError): return web.Response(status=HTTPStatus.BAD_REQUEST) if start_date is None or end_date is None: @@ -540,7 +540,9 @@ class CalendarEventView(http.HomeAssistantView): try: calendar_event_list = await entity.async_get_events( - request.app["hass"], dt.as_local(start_date), dt.as_local(end_date) + request.app["hass"], + dt_util.as_local(start_date), + dt_util.as_local(end_date), ) except HomeAssistantError as err: _LOGGER.debug("Error reading events: %s", err) diff --git a/homeassistant/components/cert_expiry/helper.py b/homeassistant/components/cert_expiry/helper.py index 219b5425b5c0..582c6118f575 100644 --- a/homeassistant/components/cert_expiry/helper.py +++ b/homeassistant/components/cert_expiry/helper.py @@ -3,7 +3,7 @@ import socket import ssl from homeassistant.core import HomeAssistant -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .const import TIMEOUT from .errors import ( @@ -52,4 +52,4 @@ async def get_cert_expiry_timestamp( raise ValidationFailure(err.args[0]) from err ts_seconds = ssl.cert_time_to_seconds(cert["notAfter"]) - return dt.utc_from_timestamp(ts_seconds) + return dt_util.utc_from_timestamp(ts_seconds) diff --git a/homeassistant/components/demo/mailbox.py b/homeassistant/components/demo/mailbox.py index 6f0b23525e59..9627383443eb 100644 --- a/homeassistant/components/demo/mailbox.py +++ b/homeassistant/components/demo/mailbox.py @@ -9,7 +9,7 @@ from typing import Any from homeassistant.components.mailbox import CONTENT_TYPE_MPEG, Mailbox, StreamError from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import dt +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -34,7 +34,9 @@ class DemoMailbox(Mailbox): self._messages: dict[str, dict[str, Any]] = {} txt = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. " for idx in range(0, 10): - msgtime = int(dt.as_timestamp(dt.utcnow()) - 3600 * 24 * (10 - idx)) + msgtime = int( + dt_util.as_timestamp(dt_util.utcnow()) - 3600 * 24 * (10 - idx) + ) msgtxt = f"Message {idx + 1}. {txt * (1 + idx * (idx % 2))}" msgsha = sha1(msgtxt.encode("utf-8")).hexdigest() msg = { diff --git a/tests/components/advantage_air/test_binary_sensor.py b/tests/components/advantage_air/test_binary_sensor.py index 37e816b366b0..8f2183d49c5f 100644 --- a/tests/components/advantage_air/test_binary_sensor.py +++ b/tests/components/advantage_air/test_binary_sensor.py @@ -5,7 +5,7 @@ from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from . import ( TEST_SET_RESPONSE, @@ -88,7 +88,7 @@ async def test_binary_sensor_async_setup_entry( async_fire_time_changed( hass, - dt.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), ) await hass.async_block_till_done() @@ -110,7 +110,7 @@ async def test_binary_sensor_async_setup_entry( async_fire_time_changed( hass, - dt.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), ) await hass.async_block_till_done() diff --git a/tests/components/advantage_air/test_sensor.py b/tests/components/advantage_air/test_sensor.py index 2a7be320be62..d2c290a97dee 100644 --- a/tests/components/advantage_air/test_sensor.py +++ b/tests/components/advantage_air/test_sensor.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from . import ( TEST_SET_RESPONSE, @@ -144,7 +144,7 @@ async def test_sensor_platform( async_fire_time_changed( hass, - dt.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), ) await hass.async_block_till_done() diff --git a/tests/components/anova/test_sensor.py b/tests/components/anova/test_sensor.py index 94ce61e5b211..0ce5c7a4d0a5 100644 --- a/tests/components/anova/test_sensor.py +++ b/tests/components/anova/test_sensor.py @@ -8,7 +8,7 @@ from anova_wifi import AnovaApi, AnovaOffline from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from . import async_init_integration @@ -54,7 +54,7 @@ async def test_update_failed(hass: HomeAssistant, anova_api: AnovaApi) -> None: "homeassistant.components.anova.AnovaPrecisionCooker.update", side_effect=AnovaOffline(), ): - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=61)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=61)) await hass.async_block_till_done() state = hass.states.get("sensor.anova_precision_cooker_water_temperature") diff --git a/tests/components/broadlink/test_heartbeat.py b/tests/components/broadlink/test_heartbeat.py index e56cbc5e2ac4..566dd4ba86fe 100644 --- a/tests/components/broadlink/test_heartbeat.py +++ b/tests/components/broadlink/test_heartbeat.py @@ -5,7 +5,7 @@ import pytest from homeassistant.components.broadlink.heartbeat import BroadlinkHeartbeat from homeassistant.core import HomeAssistant -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from . import get_device @@ -48,7 +48,7 @@ async def test_heartbeat_trigger_right_time(hass: HomeAssistant) -> None: with patch(DEVICE_PING) as mock_ping: async_fire_time_changed( - hass, dt.utcnow() + BroadlinkHeartbeat.HEARTBEAT_INTERVAL + hass, dt_util.utcnow() + BroadlinkHeartbeat.HEARTBEAT_INTERVAL ) await hass.async_block_till_done() @@ -66,7 +66,7 @@ async def test_heartbeat_do_not_trigger_before_time(hass: HomeAssistant) -> None with patch(DEVICE_PING) as mock_ping: async_fire_time_changed( hass, - dt.utcnow() + BroadlinkHeartbeat.HEARTBEAT_INTERVAL // 2, + dt_util.utcnow() + BroadlinkHeartbeat.HEARTBEAT_INTERVAL // 2, ) await hass.async_block_till_done() @@ -85,7 +85,7 @@ async def test_heartbeat_unload(hass: HomeAssistant) -> None: with patch(DEVICE_PING) as mock_ping: async_fire_time_changed( - hass, dt.utcnow() + BroadlinkHeartbeat.HEARTBEAT_INTERVAL + hass, dt_util.utcnow() + BroadlinkHeartbeat.HEARTBEAT_INTERVAL ) assert mock_ping.call_count == 0 @@ -105,7 +105,7 @@ async def test_heartbeat_do_not_unload(hass: HomeAssistant) -> None: with patch(DEVICE_PING) as mock_ping: async_fire_time_changed( - hass, dt.utcnow() + BroadlinkHeartbeat.HEARTBEAT_INTERVAL + hass, dt_util.utcnow() + BroadlinkHeartbeat.HEARTBEAT_INTERVAL ) await hass.async_block_till_done() diff --git a/tests/components/broadlink/test_sensors.py b/tests/components/broadlink/test_sensors.py index 43529fae6316..f1802ce51aa3 100644 --- a/tests/components/broadlink/test_sensors.py +++ b/tests/components/broadlink/test_sensors.py @@ -7,7 +7,7 @@ from homeassistant.const import ATTR_FRIENDLY_NAME, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_component import async_update_entity -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from . import get_device @@ -384,7 +384,9 @@ async def test_scb1e_sensor_update( } target_time = ( - dt.utcnow() + BroadlinkSP4UpdateManager.SCAN_INTERVAL * 3 + timedelta(seconds=1) + dt_util.utcnow() + + BroadlinkSP4UpdateManager.SCAN_INTERVAL * 3 + + timedelta(seconds=1) ) mock_setup = await device.setup_entry(hass, mock_api=mock_api) diff --git a/tests/components/caldav/test_calendar.py b/tests/components/caldav/test_calendar.py index 87aec3a6f5d1..ddf089c10c08 100644 --- a/tests/components/caldav/test_calendar.py +++ b/tests/components/caldav/test_calendar.py @@ -9,7 +9,7 @@ import pytest from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from homeassistant.util import dt +from homeassistant.util import dt as dt_util DEVICE_DATA = {"name": "Private Calendar", "device_id": "Private Calendar"} @@ -360,7 +360,7 @@ def get_api_events(hass_client): def _local_datetime(hours, minutes): """Build a datetime object for testing in the correct timezone.""" - return dt.as_local(datetime.datetime(2017, 11, 27, hours, minutes, 0)) + return dt_util.as_local(datetime.datetime(2017, 11, 27, hours, minutes, 0)) def _mocked_dav_client(*names, calendars=None): @@ -693,7 +693,9 @@ async def test_all_day_event_returned_early( hass, calendar, config, - datetime.datetime(2017, 11, 27, 0, 30).replace(tzinfo=dt.DEFAULT_TIME_ZONE), + datetime.datetime(2017, 11, 27, 0, 30).replace( + tzinfo=dt_util.DEFAULT_TIME_ZONE + ), ) @@ -711,7 +713,9 @@ async def test_all_day_event_returned_mid( hass, calendar, config, - datetime.datetime(2017, 11, 27, 12, 30).replace(tzinfo=dt.DEFAULT_TIME_ZONE), + datetime.datetime(2017, 11, 27, 12, 30).replace( + tzinfo=dt_util.DEFAULT_TIME_ZONE + ), ) @@ -729,7 +733,9 @@ async def test_all_day_event_returned_late( hass, calendar, config, - datetime.datetime(2017, 11, 27, 23, 30).replace(tzinfo=dt.DEFAULT_TIME_ZONE), + datetime.datetime(2017, 11, 27, 23, 30).replace( + tzinfo=dt_util.DEFAULT_TIME_ZONE + ), ) @@ -883,7 +889,7 @@ async def test_event_rrule_all_day_early(hass: HomeAssistant, calendar, set_tz) hass, calendar, config, - datetime.datetime(2016, 12, 1, 0, 30).replace(tzinfo=dt.DEFAULT_TIME_ZONE), + datetime.datetime(2016, 12, 1, 0, 30).replace(tzinfo=dt_util.DEFAULT_TIME_ZONE), ) @@ -899,7 +905,9 @@ async def test_event_rrule_all_day_mid(hass: HomeAssistant, calendar, set_tz) -> hass, calendar, config, - datetime.datetime(2016, 12, 1, 17, 30).replace(tzinfo=dt.DEFAULT_TIME_ZONE), + datetime.datetime(2016, 12, 1, 17, 30).replace( + tzinfo=dt_util.DEFAULT_TIME_ZONE + ), ) @@ -915,14 +923,16 @@ async def test_event_rrule_all_day_late(hass: HomeAssistant, calendar, set_tz) - hass, calendar, config, - datetime.datetime(2016, 12, 1, 23, 30).replace(tzinfo=dt.DEFAULT_TIME_ZONE), + datetime.datetime(2016, 12, 1, 23, 30).replace( + tzinfo=dt_util.DEFAULT_TIME_ZONE + ), ) @pytest.mark.parametrize("set_tz", ["utc"], indirect=True) @patch( "homeassistant.util.dt.now", - return_value=dt.as_local(datetime.datetime(2015, 11, 27, 0, 15)), + return_value=dt_util.as_local(datetime.datetime(2015, 11, 27, 0, 15)), ) async def test_event_rrule_hourly_on_first( mock_now, hass: HomeAssistant, calendar, set_tz @@ -949,7 +959,7 @@ async def test_event_rrule_hourly_on_first( @pytest.mark.parametrize("set_tz", ["utc"], indirect=True) @patch( "homeassistant.util.dt.now", - return_value=dt.as_local(datetime.datetime(2015, 11, 27, 11, 15)), + return_value=dt_util.as_local(datetime.datetime(2015, 11, 27, 11, 15)), ) async def test_event_rrule_hourly_on_last( mock_now, hass: HomeAssistant, calendar, set_tz @@ -975,7 +985,7 @@ async def test_event_rrule_hourly_on_last( @patch( "homeassistant.util.dt.now", - return_value=dt.as_local(datetime.datetime(2015, 11, 27, 0, 45)), + return_value=dt_util.as_local(datetime.datetime(2015, 11, 27, 0, 45)), ) async def test_event_rrule_hourly_off_first( mock_now, hass: HomeAssistant, calendar @@ -991,7 +1001,7 @@ async def test_event_rrule_hourly_off_first( @patch( "homeassistant.util.dt.now", - return_value=dt.as_local(datetime.datetime(2015, 11, 27, 11, 45)), + return_value=dt_util.as_local(datetime.datetime(2015, 11, 27, 11, 45)), ) async def test_event_rrule_hourly_off_last( mock_now, hass: HomeAssistant, calendar @@ -1007,7 +1017,7 @@ async def test_event_rrule_hourly_off_last( @patch( "homeassistant.util.dt.now", - return_value=dt.as_local(datetime.datetime(2015, 11, 27, 12, 15)), + return_value=dt_util.as_local(datetime.datetime(2015, 11, 27, 12, 15)), ) async def test_event_rrule_hourly_ended( mock_now, hass: HomeAssistant, calendar diff --git a/tests/components/cert_expiry/helpers.py b/tests/components/cert_expiry/helpers.py index 222f07b7803a..cf7cff511f7f 100644 --- a/tests/components/cert_expiry/helpers.py +++ b/tests/components/cert_expiry/helpers.py @@ -1,12 +1,12 @@ """Helpers for Cert Expiry tests.""" from datetime import datetime, timedelta -from homeassistant.util import dt +from homeassistant.util import dt as dt_util def static_datetime(): """Build a datetime object for testing in the correct timezone.""" - return dt.as_utc(datetime(2020, 6, 12, 8, 0, 0)) + return dt_util.as_utc(datetime(2020, 6, 12, 8, 0, 0)) def future_timestamp(days): diff --git a/tests/components/cloud/test_repairs.py b/tests/components/cloud/test_repairs.py index 3cfe93ad5d07..d010cac77add 100644 --- a/tests/components/cloud/test_repairs.py +++ b/tests/components/cloud/test_repairs.py @@ -10,7 +10,7 @@ from homeassistant.components.repairs import DOMAIN as REPAIRS_DOMAIN from homeassistant.core import HomeAssistant import homeassistant.helpers.issue_registry as ir from homeassistant.setup import async_setup_component -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from . import mock_cloud @@ -28,7 +28,7 @@ async def test_do_not_create_repair_issues_at_startup_if_not_logged_in( with patch("homeassistant.components.cloud.Cloud.is_logged_in", False): await mock_cloud(hass) - async_fire_time_changed(hass, dt.utcnow() + timedelta(hours=1)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(hours=1)) await hass.async_block_till_done() assert not issue_registry.async_get_issue( @@ -51,7 +51,7 @@ async def test_create_repair_issues_at_startup_if_logged_in( with patch("homeassistant.components.cloud.Cloud.is_logged_in", True): await mock_cloud(hass) - async_fire_time_changed(hass, dt.utcnow() + timedelta(hours=1)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(hours=1)) await hass.async_block_till_done() assert issue_registry.async_get_issue( diff --git a/tests/components/command_line/test_sensor.py b/tests/components/command_line/test_sensor.py index c888f1894a04..7491e7011f5e 100644 --- a/tests/components/command_line/test_sensor.py +++ b/tests/components/command_line/test_sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er import homeassistant.helpers.issue_registry as ir -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed @@ -123,7 +123,7 @@ async def test_template_render( # Give time for template to load async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=1), + dt_util.utcnow() + timedelta(minutes=1), ) await hass.async_block_till_done() @@ -158,7 +158,7 @@ async def test_template_render_with_quote(hass: HomeAssistant) -> None: # Give time for template to load async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=1), + dt_util.utcnow() + timedelta(minutes=1), ) await hass.async_block_till_done() diff --git a/tests/components/deconz/test_sensor.py b/tests/components/deconz/test_sensor.py index 9c5dc937448e..0d2f792b6dc8 100644 --- a/tests/components/deconz/test_sensor.py +++ b/tests/components/deconz/test_sensor.py @@ -24,7 +24,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .test_gateway import DECONZ_WEB_REQUEST, setup_deconz_integration @@ -785,7 +785,7 @@ async def test_sensors( async_fire_time_changed( hass, - dt.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), ) await hass.async_block_till_done() diff --git a/tests/components/demo/test_vacuum.py b/tests/components/demo/test_vacuum.py index 8b17e1726a97..38bb9418091e 100644 --- a/tests/components/demo/test_vacuum.py +++ b/tests/components/demo/test_vacuum.py @@ -38,7 +38,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed, async_mock_service from tests.components.vacuum import common @@ -179,7 +179,7 @@ async def test_methods(hass: HomeAssistant) -> None: state = hass.states.get(ENTITY_VACUUM_STATE) assert state.state == STATE_RETURNING - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=31)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=31)) await hass.async_block_till_done() state = hass.states.get(ENTITY_VACUUM_STATE) assert state.state == STATE_DOCKED diff --git a/tests/components/devolo_home_network/test_binary_sensor.py b/tests/components/devolo_home_network/test_binary_sensor.py index 5906112ffd14..fc9cb2329897 100644 --- a/tests/components/devolo_home_network/test_binary_sensor.py +++ b/tests/components/devolo_home_network/test_binary_sensor.py @@ -18,7 +18,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from . import configure_integration from .const import PLCNET_ATTACHED @@ -66,7 +66,7 @@ async def test_update_attached_to_router( mock_device.plcnet.async_get_network_overview = AsyncMock( side_effect=DeviceUnavailable ) - async_fire_time_changed(hass, dt.utcnow() + LONG_UPDATE_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + LONG_UPDATE_INTERVAL) await hass.async_block_till_done() state = hass.states.get(state_key) @@ -77,7 +77,7 @@ async def test_update_attached_to_router( mock_device.plcnet.async_get_network_overview = AsyncMock( return_value=PLCNET_ATTACHED ) - async_fire_time_changed(hass, dt.utcnow() + LONG_UPDATE_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + LONG_UPDATE_INTERVAL) await hass.async_block_till_done() state = hass.states.get(state_key) diff --git a/tests/components/devolo_home_network/test_device_tracker.py b/tests/components/devolo_home_network/test_device_tracker.py index 963956abd905..4ccb16644ce7 100644 --- a/tests/components/devolo_home_network/test_device_tracker.py +++ b/tests/components/devolo_home_network/test_device_tracker.py @@ -18,7 +18,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from . import configure_integration from .const import CONNECTED_STATIONS, DISCOVERY_INFO, NO_CONNECTED_STATIONS @@ -40,13 +40,13 @@ async def test_device_tracker( entry = configure_integration(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - async_fire_time_changed(hass, dt.utcnow() + LONG_UPDATE_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + LONG_UPDATE_INTERVAL) await hass.async_block_till_done() # Enable entity entity_registry.async_update_entity(state_key, disabled_by=None) await hass.async_block_till_done() - async_fire_time_changed(hass, dt.utcnow() + LONG_UPDATE_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + LONG_UPDATE_INTERVAL) await hass.async_block_till_done() state = hass.states.get(state_key) @@ -62,7 +62,7 @@ async def test_device_tracker( mock_device.device.async_get_wifi_connected_station = AsyncMock( return_value=NO_CONNECTED_STATIONS ) - async_fire_time_changed(hass, dt.utcnow() + LONG_UPDATE_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + LONG_UPDATE_INTERVAL) await hass.async_block_till_done() state = hass.states.get(state_key) @@ -73,7 +73,7 @@ async def test_device_tracker( mock_device.device.async_get_wifi_connected_station = AsyncMock( side_effect=DeviceUnavailable ) - async_fire_time_changed(hass, dt.utcnow() + LONG_UPDATE_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + LONG_UPDATE_INTERVAL) await hass.async_block_till_done() state = hass.states.get(state_key) diff --git a/tests/components/devolo_home_network/test_sensor.py b/tests/components/devolo_home_network/test_sensor.py index fc8afbe1ae8f..0511544224ad 100644 --- a/tests/components/devolo_home_network/test_sensor.py +++ b/tests/components/devolo_home_network/test_sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.sensor import DOMAIN, SensorStateClass from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_UNAVAILABLE, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from . import configure_integration from .mock import MockDevice @@ -57,7 +57,7 @@ async def test_update_connected_wifi_clients( mock_device.device.async_get_wifi_connected_station = AsyncMock( side_effect=DeviceUnavailable ) - async_fire_time_changed(hass, dt.utcnow() + SHORT_UPDATE_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + SHORT_UPDATE_INTERVAL) await hass.async_block_till_done() state = hass.states.get(state_key) @@ -66,7 +66,7 @@ async def test_update_connected_wifi_clients( # Emulate state change mock_device.reset() - async_fire_time_changed(hass, dt.utcnow() + SHORT_UPDATE_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + SHORT_UPDATE_INTERVAL) await hass.async_block_till_done() state = hass.states.get(state_key) @@ -103,7 +103,7 @@ async def test_update_neighboring_wifi_networks( mock_device.device.async_get_wifi_neighbor_access_points = AsyncMock( side_effect=DeviceUnavailable ) - async_fire_time_changed(hass, dt.utcnow() + LONG_UPDATE_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + LONG_UPDATE_INTERVAL) await hass.async_block_till_done() state = hass.states.get(state_key) @@ -112,7 +112,7 @@ async def test_update_neighboring_wifi_networks( # Emulate state change mock_device.reset() - async_fire_time_changed(hass, dt.utcnow() + LONG_UPDATE_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + LONG_UPDATE_INTERVAL) await hass.async_block_till_done() state = hass.states.get(state_key) @@ -148,7 +148,7 @@ async def test_update_connected_plc_devices( mock_device.plcnet.async_get_network_overview = AsyncMock( side_effect=DeviceUnavailable ) - async_fire_time_changed(hass, dt.utcnow() + LONG_UPDATE_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + LONG_UPDATE_INTERVAL) await hass.async_block_till_done() state = hass.states.get(state_key) @@ -157,7 +157,7 @@ async def test_update_connected_plc_devices( # Emulate state change mock_device.reset() - async_fire_time_changed(hass, dt.utcnow() + LONG_UPDATE_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + LONG_UPDATE_INTERVAL) await hass.async_block_till_done() state = hass.states.get(state_key) diff --git a/tests/components/devolo_home_network/test_switch.py b/tests/components/devolo_home_network/test_switch.py index 47e09a034ca7..b2f0e54f9713 100644 --- a/tests/components/devolo_home_network/test_switch.py +++ b/tests/components/devolo_home_network/test_switch.py @@ -24,7 +24,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.update_coordinator import REQUEST_REFRESH_DEFAULT_COOLDOWN -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from . import configure_integration from .mock import MockDevice @@ -90,7 +90,7 @@ async def test_update_enable_guest_wifi( mock_device.device.async_get_wifi_guest_access.return_value = WifiGuestAccessGet( enabled=True ) - async_fire_time_changed(hass, dt.utcnow() + SHORT_UPDATE_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + SHORT_UPDATE_INTERVAL) await hass.async_block_till_done() state = hass.states.get(state_key) @@ -115,7 +115,7 @@ async def test_update_enable_guest_wifi( turn_off.assert_called_once_with(False) async_fire_time_changed( - hass, dt.utcnow() + timedelta(seconds=REQUEST_REFRESH_DEFAULT_COOLDOWN) + hass, dt_util.utcnow() + timedelta(seconds=REQUEST_REFRESH_DEFAULT_COOLDOWN) ) await hass.async_block_till_done() @@ -137,7 +137,7 @@ async def test_update_enable_guest_wifi( turn_on.assert_called_once_with(True) async_fire_time_changed( - hass, dt.utcnow() + timedelta(seconds=REQUEST_REFRESH_DEFAULT_COOLDOWN) + hass, dt_util.utcnow() + timedelta(seconds=REQUEST_REFRESH_DEFAULT_COOLDOWN) ) await hass.async_block_till_done() @@ -177,7 +177,7 @@ async def test_update_enable_leds( # Emulate state change mock_device.device.async_get_led_setting.return_value = True - async_fire_time_changed(hass, dt.utcnow() + SHORT_UPDATE_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + SHORT_UPDATE_INTERVAL) await hass.async_block_till_done() state = hass.states.get(state_key) @@ -200,7 +200,7 @@ async def test_update_enable_leds( turn_off.assert_called_once_with(False) async_fire_time_changed( - hass, dt.utcnow() + timedelta(seconds=REQUEST_REFRESH_DEFAULT_COOLDOWN) + hass, dt_util.utcnow() + timedelta(seconds=REQUEST_REFRESH_DEFAULT_COOLDOWN) ) await hass.async_block_till_done() @@ -220,7 +220,7 @@ async def test_update_enable_leds( turn_on.assert_called_once_with(True) async_fire_time_changed( - hass, dt.utcnow() + timedelta(seconds=REQUEST_REFRESH_DEFAULT_COOLDOWN) + hass, dt_util.utcnow() + timedelta(seconds=REQUEST_REFRESH_DEFAULT_COOLDOWN) ) await hass.async_block_till_done() @@ -268,7 +268,7 @@ async def test_device_failure( api = getattr(mock_device.device, get_method) api.side_effect = DeviceUnavailable - async_fire_time_changed(hass, dt.utcnow() + update_interval) + async_fire_time_changed(hass, dt_util.utcnow() + update_interval) await hass.async_block_till_done() state = hass.states.get(state_key) diff --git a/tests/components/dnsip/test_sensor.py b/tests/components/dnsip/test_sensor.py index 75e5f5ebf88e..1282cddc5e6b 100644 --- a/tests/components/dnsip/test_sensor.py +++ b/tests/components/dnsip/test_sensor.py @@ -17,7 +17,7 @@ from homeassistant.components.dnsip.const import ( from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_NAME, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from . import RetrieveDNS @@ -97,7 +97,7 @@ async def test_sensor_no_response(hass: HomeAssistant) -> None: ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=10), + dt_util.utcnow() + timedelta(minutes=10), ) await hass.async_block_till_done() From 2e59ae9e90d16f7c440914c32460aa90d73d59b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Tue, 30 May 2023 00:02:14 +0300 Subject: [PATCH 662/822] Import `util.dt` as `dt_util` in `components/[k-o]*` (#93760) --- .../components/moehlenhoff_alpha2/button.py | 4 ++-- homeassistant/components/mqtt_room/sensor.py | 8 ++++---- homeassistant/components/nobo_hub/climate.py | 4 ++-- .../openweathermap/weather_update_coordinator.py | 6 +++--- tests/components/knx/test_binary_sensor.py | 12 ++++++------ tests/components/knx/test_button.py | 4 ++-- tests/components/knx/test_expose.py | 6 ++++-- tests/components/knx/test_interface_device.py | 4 ++-- tests/components/knx/test_light.py | 4 ++-- tests/components/kostal_plenticore/test_number.py | 8 ++++---- tests/components/mqtt_room/test_sensor.py | 4 ++-- tests/components/nest/test_api.py | 6 +++--- tests/components/netatmo/test_camera.py | 4 ++-- tests/components/netatmo/test_init.py | 4 ++-- 14 files changed, 40 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/moehlenhoff_alpha2/button.py b/homeassistant/components/moehlenhoff_alpha2/button.py index 44dbd08ffcdf..c637909417c9 100644 --- a/homeassistant/components/moehlenhoff_alpha2/button.py +++ b/homeassistant/components/moehlenhoff_alpha2/button.py @@ -6,7 +6,7 @@ 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 homeassistant.util import dt +from homeassistant.util import dt as dt_util from . import Alpha2BaseCoordinator from .const import DOMAIN @@ -38,4 +38,4 @@ class Alpha2TimeSyncButton(CoordinatorEntity[Alpha2BaseCoordinator], ButtonEntit async def async_press(self) -> None: """Synchronize current local time from HA instance to base station.""" - await self.coordinator.base.set_datetime(dt.now()) + await self.coordinator.base.set_datetime(dt_util.now()) diff --git a/homeassistant/components/mqtt_room/sensor.py b/homeassistant/components/mqtt_room/sensor.py index 00441690b47f..1b4cdb1c5831 100644 --- a/homeassistant/components/mqtt_room/sensor.py +++ b/homeassistant/components/mqtt_room/sensor.py @@ -23,7 +23,7 @@ from homeassistant.core import HomeAssistant, callback 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, slugify +from homeassistant.util import dt as dt_util, slugify _LOGGER = logging.getLogger(__name__) @@ -122,7 +122,7 @@ class MQTTRoomSensor(SensorEntity): """Update the sensor state.""" self._state = room self._distance = distance - self._updated = dt.utcnow() + self._updated = dt_util.utcnow() self.async_write_ha_state() @@ -144,7 +144,7 @@ class MQTTRoomSensor(SensorEntity): # device is in the same room OR # device is closer to another room OR # last update from other room was too long ago - timediff = dt.utcnow() - self._updated + timediff = dt_util.utcnow() - self._updated if ( device.get(ATTR_ROOM) == self._state or device.get(ATTR_DISTANCE) < self._distance @@ -174,7 +174,7 @@ class MQTTRoomSensor(SensorEntity): if ( self._updated and self._consider_home - and dt.utcnow() - self._updated > self._consider_home + and dt_util.utcnow() - self._updated > self._consider_home ): self._state = STATE_NOT_HOME diff --git a/homeassistant/components/nobo_hub/climate.py b/homeassistant/components/nobo_hub/climate.py index 7cb69dfb79a7..d1661dce0fa5 100644 --- a/homeassistant/components/nobo_hub/climate.py +++ b/homeassistant/components/nobo_hub/climate.py @@ -28,7 +28,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .const import ( ATTR_SERIAL, @@ -158,7 +158,7 @@ class NoboZone(ClimateEntity): @callback def _read_state(self) -> None: """Read the current state from the hub. These are only local calls.""" - state = self._nobo.get_current_zone_mode(self._id, dt.now()) + state = self._nobo.get_current_zone_mode(self._id, dt_util.now()) self._attr_hvac_mode = HVACMode.AUTO self._attr_preset_mode = PRESET_NONE diff --git a/homeassistant/components/openweathermap/weather_update_coordinator.py b/homeassistant/components/openweathermap/weather_update_coordinator.py index 90e2c426d27b..4602615769ae 100644 --- a/homeassistant/components/openweathermap/weather_update_coordinator.py +++ b/homeassistant/components/openweathermap/weather_update_coordinator.py @@ -12,7 +12,7 @@ from homeassistant.components.weather import ( from homeassistant.const import UnitOfTemperature from homeassistant.helpers import sun from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import TemperatureConverter from .const import ( @@ -159,7 +159,7 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): def _convert_forecast(self, entry): """Convert the forecast data.""" forecast = { - ATTR_API_FORECAST_TIME: dt.utc_from_timestamp( + ATTR_API_FORECAST_TIME: dt_util.utc_from_timestamp( entry.reference_time("unix") ).isoformat(), ATTR_API_FORECAST_PRECIPITATION: self._calc_precipitation( @@ -252,7 +252,7 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): """Get weather condition from weather data.""" if weather_code == WEATHER_CODE_SUNNY_OR_CLEAR_NIGHT: if timestamp: - timestamp = dt.utc_from_timestamp(timestamp) + timestamp = dt_util.utc_from_timestamp(timestamp) if sun.is_up(self.hass, timestamp): return ATTR_CONDITION_SUNNY diff --git a/tests/components/knx/test_binary_sensor.py b/tests/components/knx/test_binary_sensor.py index 61b7247037ed..47715433a521 100644 --- a/tests/components/knx/test_binary_sensor.py +++ b/tests/components/knx/test_binary_sensor.py @@ -12,7 +12,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .conftest import KNXTestKit @@ -147,7 +147,7 @@ async def test_binary_sensor_ignore_internal_state( async def test_binary_sensor_counter(hass: HomeAssistant, knx: KNXTestKit) -> None: """Test KNX binary_sensor with context timeout.""" - async_fire_time_changed(hass, dt.utcnow()) + async_fire_time_changed(hass, dt_util.utcnow()) context_timeout = 1 await knx.setup_integration( @@ -172,7 +172,7 @@ async def test_binary_sensor_counter(hass: HomeAssistant, knx: KNXTestKit) -> No state = hass.states.get("binary_sensor.test") assert state.state is STATE_OFF assert state.attributes.get("counter") == 0 - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=context_timeout)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=context_timeout)) await hass.async_block_till_done() await knx.xknx.task_registry.block_till_done() # state changed twice after context timeout - once to ON with counter 1 and once to counter 0 @@ -196,7 +196,7 @@ async def test_binary_sensor_counter(hass: HomeAssistant, knx: KNXTestKit) -> No state = hass.states.get("binary_sensor.test") assert state.state is STATE_ON assert state.attributes.get("counter") == 0 - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=context_timeout)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=context_timeout)) await knx.xknx.task_registry.block_till_done() await hass.async_block_till_done() state = hass.states.get("binary_sensor.test") @@ -213,7 +213,7 @@ async def test_binary_sensor_counter(hass: HomeAssistant, knx: KNXTestKit) -> No async def test_binary_sensor_reset(hass: HomeAssistant, knx: KNXTestKit) -> None: """Test KNX binary_sensor with reset_after function.""" - async_fire_time_changed(hass, dt.utcnow()) + async_fire_time_changed(hass, dt_util.utcnow()) await knx.setup_integration( { @@ -233,7 +233,7 @@ async def test_binary_sensor_reset(hass: HomeAssistant, knx: KNXTestKit) -> None await hass.async_block_till_done() state = hass.states.get("binary_sensor.test") assert state.state is STATE_ON - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=1)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1)) await hass.async_block_till_done() await hass.async_block_till_done() # state reset after after timeout diff --git a/tests/components/knx/test_button.py b/tests/components/knx/test_button.py index eb3fee7eaf51..3e8519feb983 100644 --- a/tests/components/knx/test_button.py +++ b/tests/components/knx/test_button.py @@ -13,7 +13,7 @@ from homeassistant.components.knx.const import ( from homeassistant.components.knx.schema import ButtonSchema from homeassistant.const import CONF_NAME, CONF_TYPE from homeassistant.core import HomeAssistant -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .conftest import KNXTestKit @@ -42,7 +42,7 @@ async def test_button_simple(hass: HomeAssistant, knx: KNXTestKit) -> None: # received telegrams on button GA are ignored by the entity old_state = hass.states.get("button.test") - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=3)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=3)) await knx.receive_write("1/2/3", False) await knx.receive_write("1/2/3", True) new_state = hass.states.get("button.test") diff --git a/tests/components/knx/test_expose.py b/tests/components/knx/test_expose.py index bec76f29eeca..ca3fc5c7f587 100644 --- a/tests/components/knx/test_expose.py +++ b/tests/components/knx/test_expose.py @@ -9,7 +9,7 @@ from homeassistant.components.knx import CONF_KNX_EXPOSE, DOMAIN, KNX_ADDRESS from homeassistant.components.knx.schema import ExposeSchema from homeassistant.const import CONF_ATTRIBUTE, CONF_ENTITY_ID, CONF_TYPE from homeassistant.core import HomeAssistant -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .conftest import KNXTestKit @@ -192,7 +192,9 @@ async def test_expose_cooldown(hass: HomeAssistant, knx: KNXTestKit) -> None: hass.states.async_set(entity_id, "3", {}) await knx.assert_no_telegram() # Wait for cooldown to pass - async_fire_time_changed_exact(hass, dt.utcnow() + timedelta(seconds=cooldown_time)) + async_fire_time_changed_exact( + hass, dt_util.utcnow() + timedelta(seconds=cooldown_time) + ) await hass.async_block_till_done() await knx.assert_write("1/1/8", (3,)) diff --git a/tests/components/knx/test_interface_device.py b/tests/components/knx/test_interface_device.py index 6eab80d52cb2..9fb21b9f9b47 100644 --- a/tests/components/knx/test_interface_device.py +++ b/tests/components/knx/test_interface_device.py @@ -8,7 +8,7 @@ from homeassistant.components.knx.sensor import SCAN_INTERVAL from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .conftest import KNXTestKit @@ -47,7 +47,7 @@ async def test_diagnostic_entities( knx.xknx.connection_manager.cemi_count_outgoing_error = 2 events = async_capture_events(hass, "state_changed") - async_fire_time_changed(hass, dt.utcnow() + SCAN_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) await hass.async_block_till_done() assert len(events) == 3 # 5 polled sensors - 2 disabled diff --git a/tests/components/knx/test_light.py b/tests/components/knx/test_light.py index a445d1a6fd35..1f2f23e9cca6 100644 --- a/tests/components/knx/test_light.py +++ b/tests/components/knx/test_light.py @@ -18,7 +18,7 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_NAME, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .conftest import KNXTestKit @@ -764,7 +764,7 @@ async def test_light_rgbw_individual(hass: HomeAssistant, knx: KNXTestKit) -> No # # individual color debounce takes 0.2 seconds if not all 4 addresses received knx.assert_state("light.test", STATE_ON) async_fire_time_changed( - hass, dt.utcnow() + timedelta(seconds=XknxLight.DEBOUNCE_TIMEOUT) + hass, dt_util.utcnow() + timedelta(seconds=XknxLight.DEBOUNCE_TIMEOUT) ) await knx.xknx.task_registry.block_till_done() knx.assert_state("light.test", STATE_OFF) diff --git a/tests/components/kostal_plenticore/test_number.py b/tests/components/kostal_plenticore/test_number.py index 009184a699cd..dd5ba7127a83 100644 --- a/tests/components/kostal_plenticore/test_number.py +++ b/tests/components/kostal_plenticore/test_number.py @@ -16,7 +16,7 @@ from homeassistant.components.number import ( from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_registry import async_get -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed @@ -168,7 +168,7 @@ async def test_number_has_value( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=3)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=3)) await hass.async_block_till_done() state = hass.states.get("number.scb_battery_min_soc") @@ -191,7 +191,7 @@ async def test_number_is_unavailable( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=3)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=3)) await hass.async_block_till_done() state = hass.states.get("number.scb_battery_min_soc") @@ -214,7 +214,7 @@ async def test_set_value( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=3)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=3)) await hass.async_block_till_done() await hass.services.async_call( diff --git a/tests/components/mqtt_room/test_sensor.py b/tests/components/mqtt_room/test_sensor.py index 1d6b2980ab27..72540f49ca79 100644 --- a/tests/components/mqtt_room/test_sensor.py +++ b/tests/components/mqtt_room/test_sensor.py @@ -17,7 +17,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from tests.common import async_fire_mqtt_message from tests.typing import MqttMockHAClient @@ -110,7 +110,7 @@ async def test_room_update(hass: HomeAssistant, mqtt_mock: MqttMockHAClient) -> await assert_state(hass, LIVING_ROOM) await assert_distance(hass, 1) - time = dt.utcnow() + datetime.timedelta(seconds=7) + time = dt_util.utcnow() + datetime.timedelta(seconds=7) with patch("homeassistant.helpers.condition.dt_util.utcnow", return_value=time): await send_message(hass, BEDROOM_TOPIC, FAR_MESSAGE) await assert_state(hass, BEDROOM) diff --git a/tests/components/nest/test_api.py b/tests/components/nest/test_api.py index 422f1fe62696..91975f3559f1 100644 --- a/tests/components/nest/test_api.py +++ b/tests/components/nest/test_api.py @@ -14,7 +14,7 @@ import pytest from homeassistant.components.nest.const import API_URL, OAUTH2_TOKEN, SDM_SCOPES from homeassistant.core import HomeAssistant -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .common import CLIENT_ID, CLIENT_SECRET, PROJECT_ID, PlatformSetup from .conftest import FAKE_REFRESH_TOKEN, FAKE_TOKEN @@ -80,7 +80,7 @@ async def test_auth( creds = captured_creds assert creds.token == FAKE_TOKEN assert creds.refresh_token == FAKE_REFRESH_TOKEN - assert int(dt.as_timestamp(creds.expiry)) == int(token_expiration_time) + assert int(dt_util.as_timestamp(creds.expiry)) == int(token_expiration_time) assert creds.valid assert not creds.expired assert creds.token_uri == OAUTH2_TOKEN @@ -157,7 +157,7 @@ async def test_auth_expired_token( creds = captured_creds assert creds.token == FAKE_TOKEN assert creds.refresh_token == FAKE_REFRESH_TOKEN - assert int(dt.as_timestamp(creds.expiry)) == int(token_expiration_time) + assert int(dt_util.as_timestamp(creds.expiry)) == int(token_expiration_time) assert not creds.valid assert creds.expired assert creds.token_uri == OAUTH2_TOKEN diff --git a/tests/components/netatmo/test_camera.py b/tests/components/netatmo/test_camera.py index 8334946af07f..8bfe7176f5df 100644 --- a/tests/components/netatmo/test_camera.py +++ b/tests/components/netatmo/test_camera.py @@ -17,7 +17,7 @@ from homeassistant.components.netatmo.const import ( from homeassistant.const import CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .common import fake_post_request, selected_platforms, simulate_webhook @@ -420,7 +420,7 @@ async def test_camera_reconnect_webhook(hass: HomeAssistant, config_entry) -> No async_fire_time_changed( hass, - dt.utcnow() + timedelta(seconds=60), + dt_util.utcnow() + timedelta(seconds=60), ) await hass.async_block_till_done() assert fake_post_hits >= calls diff --git a/tests/components/netatmo/test_init.py b/tests/components/netatmo/test_init.py index 1914380521b4..f4d24e87b913 100644 --- a/tests/components/netatmo/test_init.py +++ b/tests/components/netatmo/test_init.py @@ -12,7 +12,7 @@ from homeassistant.components.netatmo import DOMAIN from homeassistant.const import CONF_WEBHOOK_ID from homeassistant.core import CoreState, HomeAssistant from homeassistant.setup import async_setup_component -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .common import ( FAKE_WEBHOOK_ACTIVATION, @@ -339,7 +339,7 @@ async def test_setup_component_with_delay(hass: HomeAssistant, config_entry) -> async_fire_time_changed( hass, - dt.utcnow() + timedelta(seconds=60), + dt_util.utcnow() + timedelta(seconds=60), ) await hass.async_block_till_done() From 940942a74ac6b72ffe92d186eef06776fd4ae78f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Tue, 30 May 2023 00:09:42 +0300 Subject: [PATCH 663/822] Import `util.dt` as `dt_util` in `components/[p-s]*` (#93761) --- tests/components/picnic/test_sensor.py | 4 +- tests/components/pjlink/test_media_player.py | 6 +-- tests/components/plex/test_button.py | 4 +- tests/components/plex/test_sensor.py | 10 ++--- tests/components/recorder/test_models.py | 24 +++++----- tests/components/risco/test_sensor.py | 6 +-- .../components/sensibo/test_binary_sensor.py | 4 +- tests/components/sensibo/test_button.py | 8 ++-- tests/components/sensibo/test_climate.py | 44 +++++++++---------- tests/components/sensibo/test_coordinator.py | 10 ++--- tests/components/sensibo/test_number.py | 4 +- tests/components/sensibo/test_select.py | 8 ++-- tests/components/sensibo/test_sensor.py | 4 +- tests/components/sensibo/test_switch.py | 16 +++---- tests/components/sensibo/test_update.py | 4 +- tests/components/shelly/__init__.py | 6 +-- tests/components/shelly/test_coordinator.py | 24 +++++----- tests/components/shelly/test_utils.py | 10 ++--- tests/components/smarttub/__init__.py | 4 +- tests/components/sonos/test_switch.py | 4 +- .../soundtouch/test_media_player.py | 4 +- tests/components/sql/test_sensor.py | 6 +-- tests/components/switcher_kis/test_init.py | 6 +-- 23 files changed, 111 insertions(+), 109 deletions(-) diff --git a/tests/components/picnic/test_sensor.py b/tests/components/picnic/test_sensor.py index 0d003547ef2b..c47226d407e7 100644 --- a/tests/components/picnic/test_sensor.py +++ b/tests/components/picnic/test_sensor.py @@ -19,7 +19,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, @@ -177,7 +177,7 @@ class TestPicnicSensor(unittest.IsolatedAsyncioTestCase): # Trigger a reload of the data async_fire_time_changed( self.hass, - dt.utcnow() + dt_util.utcnow() + timedelta(seconds=config_entries.RELOAD_AFTER_UPDATE_DELAY + 1), ) await self.hass.async_block_till_done() diff --git a/tests/components/pjlink/test_media_player.py b/tests/components/pjlink/test_media_player.py index 686ece5b7ecc..941a3cefe3a8 100644 --- a/tests/components/pjlink/test_media_player.py +++ b/tests/components/pjlink/test_media_player.py @@ -12,7 +12,7 @@ import homeassistant.components.media_player as media_player from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from tests.common import assert_setup_component, async_fire_time_changed @@ -206,7 +206,7 @@ async def test_update_unavailable(projector_from_address, hass: HomeAssistant) - assert state.state == "off" projector_from_address.side_effect = socket.timeout - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=10)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10)) await hass.async_block_till_done() state = hass.states.get("media_player.test") @@ -235,7 +235,7 @@ async def test_unavailable_time(mocked_projector, hass: HomeAssistant) -> None: assert state.attributes["is_volume_muted"] is not False mocked_projector.get_power.side_effect = ProjectorError("unavailable time") - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=10)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10)) await hass.async_block_till_done() state = hass.states.get("media_player.test") diff --git a/tests/components/plex/test_button.py b/tests/components/plex/test_button.py index d2576c379a7b..4ac79ed0b7e0 100644 --- a/tests/components/plex/test_button.py +++ b/tests/components/plex/test_button.py @@ -6,7 +6,7 @@ from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRE from homeassistant.components.plex.const import DEBOUNCE_TIMEOUT from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed @@ -23,7 +23,7 @@ async def test_scan_clients_button_schedule( async_fire_time_changed( hass, - dt.utcnow() + timedelta(seconds=DEBOUNCE_TIMEOUT), + dt_util.utcnow() + timedelta(seconds=DEBOUNCE_TIMEOUT), ) assert await hass.services.async_call( diff --git a/tests/components/plex/test_sensor.py b/tests/components/plex/test_sensor.py index bd8654baf581..9c73bf9f915a 100644 --- a/tests/components/plex/test_sensor.py +++ b/tests/components/plex/test_sensor.py @@ -13,7 +13,7 @@ from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .helpers import trigger_plex_update, wait_for_debouncer @@ -125,7 +125,7 @@ async def test_library_sensor_values( async_fire_time_changed( hass, - dt.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), ) media = [MockPlexTVEpisode()] @@ -196,7 +196,7 @@ async def test_library_sensor_values( async_fire_time_changed( hass, - dt.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), ) media = [MockPlexMovie()] @@ -214,7 +214,7 @@ async def test_library_sensor_values( async_dispatcher_send( hass, PLEX_UPDATE_LIBRARY_SIGNAL.format(mock_plex_server.machine_identifier) ) - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=3)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=3)) await hass.async_block_till_done() library_movies_sensor = hass.states.get("sensor.plex_server_1_library_movies") @@ -232,7 +232,7 @@ async def test_library_sensor_values( async_fire_time_changed( hass, - dt.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), ) media = [MockPlexMusic()] diff --git a/tests/components/recorder/test_models.py b/tests/components/recorder/test_models.py index d1058c38b705..c73a0db6c761 100644 --- a/tests/components/recorder/test_models.py +++ b/tests/components/recorder/test_models.py @@ -24,7 +24,7 @@ from homeassistant.const import EVENT_STATE_CHANGED import homeassistant.core as ha from homeassistant.core import HomeAssistant from homeassistant.exceptions import InvalidEntityFormatError -from homeassistant.util import dt, dt as dt_util +from homeassistant.util import dt as dt_util def test_from_event_to_db_event() -> None: @@ -85,7 +85,7 @@ def test_from_event_to_db_state_attributes() -> None: def test_repr() -> None: """Test converting event to db state repr.""" attrs = {"this_attr": True} - fixed_time = datetime(2016, 7, 9, 11, 0, 0, tzinfo=dt.UTC, microsecond=432432) + fixed_time = datetime(2016, 7, 9, 11, 0, 0, tzinfo=dt_util.UTC, microsecond=432432) state = ha.State( "sensor.temperature", "18", @@ -105,7 +105,7 @@ def test_repr() -> None: def test_states_repr_without_timestamp() -> None: """Test repr for a state without last_updated_ts.""" - fixed_time = datetime(2016, 7, 9, 11, 0, 0, tzinfo=dt.UTC, microsecond=432432) + fixed_time = datetime(2016, 7, 9, 11, 0, 0, tzinfo=dt_util.UTC, microsecond=432432) states = States( entity_id="sensor.temp", attributes=None, @@ -123,7 +123,7 @@ def test_states_repr_without_timestamp() -> None: def test_events_repr_without_timestamp() -> None: """Test repr for an event without time_fired_ts.""" - fixed_time = datetime(2016, 7, 9, 11, 0, 0, tzinfo=dt.UTC, microsecond=432432) + fixed_time = datetime(2016, 7, 9, 11, 0, 0, tzinfo=dt_util.UTC, microsecond=432432) events = Events( event_type="any", event_data=None, @@ -180,7 +180,7 @@ def test_states_from_native_invalid_entity_id() -> None: async def test_process_timestamp() -> None: """Test processing time stamp to UTC.""" - datetime_with_tzinfo = datetime(2016, 7, 9, 11, 0, 0, tzinfo=dt.UTC) + datetime_with_tzinfo = datetime(2016, 7, 9, 11, 0, 0, tzinfo=dt_util.UTC) datetime_without_tzinfo = datetime(2016, 7, 9, 11, 0, 0) est = dt_util.get_time_zone("US/Eastern") datetime_est_timezone = datetime(2016, 7, 9, 11, 0, 0, tzinfo=est) @@ -190,26 +190,26 @@ async def test_process_timestamp() -> None: datetime_hst_timezone = datetime(2016, 7, 9, 11, 0, 0, tzinfo=hst) assert process_timestamp(datetime_with_tzinfo) == datetime( - 2016, 7, 9, 11, 0, 0, tzinfo=dt.UTC + 2016, 7, 9, 11, 0, 0, tzinfo=dt_util.UTC ) assert process_timestamp(datetime_without_tzinfo) == datetime( - 2016, 7, 9, 11, 0, 0, tzinfo=dt.UTC + 2016, 7, 9, 11, 0, 0, tzinfo=dt_util.UTC ) assert process_timestamp(datetime_est_timezone) == datetime( - 2016, 7, 9, 15, 0, tzinfo=dt.UTC + 2016, 7, 9, 15, 0, tzinfo=dt_util.UTC ) assert process_timestamp(datetime_nst_timezone) == datetime( - 2016, 7, 9, 13, 30, tzinfo=dt.UTC + 2016, 7, 9, 13, 30, tzinfo=dt_util.UTC ) assert process_timestamp(datetime_hst_timezone) == datetime( - 2016, 7, 9, 21, 0, tzinfo=dt.UTC + 2016, 7, 9, 21, 0, tzinfo=dt_util.UTC ) assert process_timestamp(None) is None async def test_process_timestamp_to_utc_isoformat() -> None: """Test processing time stamp to UTC isoformat.""" - datetime_with_tzinfo = datetime(2016, 7, 9, 11, 0, 0, tzinfo=dt.UTC) + datetime_with_tzinfo = datetime(2016, 7, 9, 11, 0, 0, tzinfo=dt_util.UTC) datetime_without_tzinfo = datetime(2016, 7, 9, 11, 0, 0) est = dt_util.get_time_zone("US/Eastern") datetime_est_timezone = datetime(2016, 7, 9, 11, 0, 0, tzinfo=est) @@ -409,7 +409,7 @@ async def test_process_datetime_to_timestamp_mirrors_utc_isoformat_behavior( ) -> None: """Test process_datetime_to_timestamp mirrors process_timestamp_to_utc_isoformat.""" hass.config.set_time_zone(time_zone) - datetime_with_tzinfo = datetime(2016, 7, 9, 11, 0, 0, tzinfo=dt.UTC) + datetime_with_tzinfo = datetime(2016, 7, 9, 11, 0, 0, tzinfo=dt_util.UTC) datetime_without_tzinfo = datetime(2016, 7, 9, 11, 0, 0) est = dt_util.get_time_zone("US/Eastern") datetime_est_timezone = datetime(2016, 7, 9, 11, 0, 0, tzinfo=est) diff --git a/tests/components/risco/test_sensor.py b/tests/components/risco/test_sensor.py index cb7e926a144b..e8bae275cc20 100644 --- a/tests/components/risco/test_sensor.py +++ b/tests/components/risco/test_sensor.py @@ -11,7 +11,7 @@ from homeassistant.components.risco import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed @@ -137,7 +137,7 @@ def _check_state(hass, category, entity_id): event_index = CATEGORIES_TO_EVENTS[category] event = TEST_EVENTS[event_index] state = hass.states.get(entity_id) - assert state.state == dt.parse_datetime(event.time).isoformat() + assert state.state == dt_util.parse_datetime(event.time).isoformat() assert state.attributes["category_id"] == event.category_id assert state.attributes["category_name"] == event.category_name assert state.attributes["type_id"] == event.type_id @@ -192,7 +192,7 @@ async def test_cloud_setup( "homeassistant.components.risco.Store.async_load", return_value={LAST_EVENT_TIMESTAMP_KEY: TEST_EVENTS[0].time}, ): - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=65)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=65)) await hass.async_block_till_done() events_mock.assert_awaited_once_with(TEST_EVENTS[0].time, 10) diff --git a/tests/components/sensibo/test_binary_sensor.py b/tests/components/sensibo/test_binary_sensor.py index d99dd2e8715e..bb190908847c 100644 --- a/tests/components/sensibo/test_binary_sensor.py +++ b/tests/components/sensibo/test_binary_sensor.py @@ -9,7 +9,7 @@ import pytest from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed @@ -53,7 +53,7 @@ async def test_binary_sensor( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() diff --git a/tests/components/sensibo/test_button.py b/tests/components/sensibo/test_button.py index 77c70d6b55cb..da6a68af2d1c 100644 --- a/tests/components/sensibo/test_button.py +++ b/tests/components/sensibo/test_button.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed @@ -35,7 +35,9 @@ async def test_button( assert state_filter_clean.state is STATE_ON assert state_filter_last_reset.state == "2022-03-12T15:24:26+00:00" - today = datetime(datetime.now().year + 1, 6, 19, 20, 0, 0).replace(tzinfo=dt.UTC) + today = datetime(datetime.now().year + 1, 6, 19, 20, 0, 0).replace( + tzinfo=dt_util.UTC + ) today_str = today.isoformat() freezer.move_to(today) @@ -69,7 +71,7 @@ async def test_button( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() diff --git a/tests/components/sensibo/test_climate.py b/tests/components/sensibo/test_climate.py index be5b539fa069..b2108d3e6f4c 100644 --- a/tests/components/sensibo/test_climate.py +++ b/tests/components/sensibo/test_climate.py @@ -55,7 +55,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed @@ -162,7 +162,7 @@ async def test_climate_fan( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() @@ -228,7 +228,7 @@ async def test_climate_swing( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() @@ -381,7 +381,7 @@ async def test_climate_temperatures( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() @@ -434,7 +434,7 @@ async def test_climate_temperature_is_none( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() @@ -488,7 +488,7 @@ async def test_climate_hvac_mode( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() @@ -520,7 +520,7 @@ async def test_climate_hvac_mode( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() @@ -559,7 +559,7 @@ async def test_climate_on_off( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() @@ -619,7 +619,7 @@ async def test_climate_service_failed( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() @@ -658,7 +658,7 @@ async def test_climate_assumed_state( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() @@ -707,7 +707,7 @@ async def test_climate_no_fan_no_swing( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() @@ -733,7 +733,7 @@ async def test_climate_set_timer( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() @@ -803,7 +803,7 @@ async def test_climate_set_timer( monkeypatch.setattr( get_data.parsed["ABC999111"], "timer_time", - datetime(2022, 6, 6, 12, 00, 00, tzinfo=dt.UTC), + datetime(2022, 6, 6, 12, 00, 00, tzinfo=dt_util.UTC), ) with patch( @@ -812,7 +812,7 @@ async def test_climate_set_timer( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() @@ -837,7 +837,7 @@ async def test_climate_pure_boost( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() @@ -908,7 +908,7 @@ async def test_climate_pure_boost( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() @@ -941,7 +941,7 @@ async def test_climate_climate_react( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() @@ -1075,7 +1075,7 @@ async def test_climate_climate_react( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() @@ -1104,7 +1104,7 @@ async def test_climate_climate_react_fahrenheit( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() @@ -1218,7 +1218,7 @@ async def test_climate_climate_react_fahrenheit( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() @@ -1247,7 +1247,7 @@ async def test_climate_full_ac_state( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() @@ -1311,7 +1311,7 @@ async def test_climate_full_ac_state( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() diff --git a/tests/components/sensibo/test_coordinator.py b/tests/components/sensibo/test_coordinator.py index f0b9d60f1127..3c02fb0d3a98 100644 --- a/tests/components/sensibo/test_coordinator.py +++ b/tests/components/sensibo/test_coordinator.py @@ -12,7 +12,7 @@ from homeassistant.components.sensibo.const import DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from . import ENTRY_CONFIG @@ -54,7 +54,7 @@ async def test_coordinator( mock_data.reset_mock() mock_data.side_effect = SensiboError("info") - async_fire_time_changed(hass, dt.utcnow() + timedelta(minutes=1)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=1)) await hass.async_block_till_done() mock_data.assert_called_once() state = hass.states.get("climate.hallway") @@ -63,7 +63,7 @@ async def test_coordinator( mock_data.return_value = SensiboData(raw={}, parsed={}) mock_data.side_effect = None - async_fire_time_changed(hass, dt.utcnow() + timedelta(minutes=3)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=3)) await hass.async_block_till_done() mock_data.assert_called_once() state = hass.states.get("climate.hallway") @@ -75,7 +75,7 @@ async def test_coordinator( mock_data.return_value = get_data mock_data.side_effect = None - async_fire_time_changed(hass, dt.utcnow() + timedelta(minutes=5)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5)) await hass.async_block_till_done() mock_data.assert_called_once() state = hass.states.get("climate.hallway") @@ -83,7 +83,7 @@ async def test_coordinator( mock_data.reset_mock() mock_data.side_effect = AuthenticationError("info") - async_fire_time_changed(hass, dt.utcnow() + timedelta(minutes=7)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=7)) await hass.async_block_till_done() mock_data.assert_called_once() state = hass.states.get("climate.hallway") diff --git a/tests/components/sensibo/test_number.py b/tests/components/sensibo/test_number.py index 1f9683559601..bdf3e5721c7b 100644 --- a/tests/components/sensibo/test_number.py +++ b/tests/components/sensibo/test_number.py @@ -16,7 +16,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed @@ -43,7 +43,7 @@ async def test_number( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() diff --git a/tests/components/sensibo/test_select.py b/tests/components/sensibo/test_select.py index b3b9140a2bbd..7d8e37314159 100644 --- a/tests/components/sensibo/test_select.py +++ b/tests/components/sensibo/test_select.py @@ -16,7 +16,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed @@ -42,7 +42,7 @@ async def test_select( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() @@ -76,7 +76,7 @@ async def test_select_set_option( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() @@ -122,7 +122,7 @@ async def test_select_set_option( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() diff --git a/tests/components/sensibo/test_sensor.py b/tests/components/sensibo/test_sensor.py index 8d3f705215c2..003c2f27903d 100644 --- a/tests/components/sensibo/test_sensor.py +++ b/tests/components/sensibo/test_sensor.py @@ -9,7 +9,7 @@ import pytest from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed @@ -61,7 +61,7 @@ async def test_sensor( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() diff --git a/tests/components/sensibo/test_switch.py b/tests/components/sensibo/test_switch.py index ba0755bd861f..c6d47ceed666 100644 --- a/tests/components/sensibo/test_switch.py +++ b/tests/components/sensibo/test_switch.py @@ -18,7 +18,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed @@ -62,7 +62,7 @@ async def test_switch_timer( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() state1 = hass.states.get("switch.hallway_timer") @@ -95,7 +95,7 @@ async def test_switch_timer( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() @@ -140,7 +140,7 @@ async def test_switch_pure_boost( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() state1 = hass.states.get("switch.kitchen_pure_boost") @@ -171,7 +171,7 @@ async def test_switch_pure_boost( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() @@ -262,7 +262,7 @@ async def test_switch_climate_react( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() state1 = hass.states.get("switch.hallway_climate_react") @@ -293,7 +293,7 @@ async def test_switch_climate_react( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() @@ -317,7 +317,7 @@ async def test_switch_climate_react_no_data( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() diff --git a/tests/components/sensibo/test_update.py b/tests/components/sensibo/test_update.py index 406f4a8d70f2..c65ee5995eeb 100644 --- a/tests/components/sensibo/test_update.py +++ b/tests/components/sensibo/test_update.py @@ -10,7 +10,7 @@ import pytest from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed @@ -39,7 +39,7 @@ async def test_select( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() diff --git a/tests/components/shelly/__init__.py b/tests/components/shelly/__init__.py index 346eed45baf8..67f47b0e7e3e 100644 --- a/tests/components/shelly/__init__.py +++ b/tests/components/shelly/__init__.py @@ -20,7 +20,7 @@ from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac from homeassistant.helpers.entity_registry import async_get -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed @@ -80,14 +80,14 @@ def inject_rpc_device_event( async def mock_rest_update(hass: HomeAssistant, seconds=REST_SENSORS_UPDATE_INTERVAL): """Move time to create REST sensors update event.""" - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=seconds)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=seconds)) await hass.async_block_till_done() async def mock_polling_rpc_update(hass: HomeAssistant): """Move time to create polling RPC sensors update event.""" async_fire_time_changed( - hass, dt.utcnow() + timedelta(seconds=RPC_SENSORS_POLLING_INTERVAL) + hass, dt_util.utcnow() + timedelta(seconds=RPC_SENSORS_POLLING_INTERVAL) ) await hass.async_block_till_done() diff --git a/tests/components/shelly/test_coordinator.py b/tests/components/shelly/test_coordinator.py index 8dc4e092f2f3..2f267a208ca2 100644 --- a/tests/components/shelly/test_coordinator.py +++ b/tests/components/shelly/test_coordinator.py @@ -24,7 +24,7 @@ from homeassistant.helpers.device_registry import ( async_entries_for_config_entry, async_get as async_get_dev_reg, ) -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from . import ( init_integration, @@ -64,7 +64,7 @@ async def test_block_reload_on_cfg_change( # Wait for debouncer async_fire_time_changed( - hass, dt.utcnow() + timedelta(seconds=ENTRY_RELOAD_COOLDOWN) + hass, dt_util.utcnow() + timedelta(seconds=ENTRY_RELOAD_COOLDOWN) ) await hass.async_block_till_done() @@ -94,7 +94,7 @@ async def test_block_no_reload_on_bulb_changes( # Wait for debouncer async_fire_time_changed( - hass, dt.utcnow() + timedelta(seconds=ENTRY_RELOAD_COOLDOWN) + hass, dt_util.utcnow() + timedelta(seconds=ENTRY_RELOAD_COOLDOWN) ) await hass.async_block_till_done() @@ -110,7 +110,7 @@ async def test_block_no_reload_on_bulb_changes( # Wait for debouncer async_fire_time_changed( - hass, dt.utcnow() + timedelta(seconds=ENTRY_RELOAD_COOLDOWN) + hass, dt_util.utcnow() + timedelta(seconds=ENTRY_RELOAD_COOLDOWN) ) await hass.async_block_till_done() @@ -132,7 +132,7 @@ async def test_block_polling_auth_error( # Move time to generate polling async_fire_time_changed( - hass, dt.utcnow() + timedelta(seconds=UPDATE_PERIOD_MULTIPLIER * 15) + hass, dt_util.utcnow() + timedelta(seconds=UPDATE_PERIOD_MULTIPLIER * 15) ) await hass.async_block_till_done() @@ -198,7 +198,7 @@ async def test_block_polling_connection_error( # Move time to generate polling async_fire_time_changed( - hass, dt.utcnow() + timedelta(seconds=UPDATE_PERIOD_MULTIPLIER * 15) + hass, dt_util.utcnow() + timedelta(seconds=UPDATE_PERIOD_MULTIPLIER * 15) ) await hass.async_block_till_done() @@ -242,7 +242,7 @@ async def test_block_sleeping_device_no_periodic_updates( # Move time to generate polling async_fire_time_changed( - hass, dt.utcnow() + timedelta(seconds=SLEEP_PERIOD_MULTIPLIER * 1000) + hass, dt_util.utcnow() + timedelta(seconds=SLEEP_PERIOD_MULTIPLIER * 1000) ) await hass.async_block_till_done() @@ -328,7 +328,7 @@ async def test_rpc_reload_on_cfg_change( # Wait for debouncer async_fire_time_changed( - hass, dt.utcnow() + timedelta(seconds=ENTRY_RELOAD_COOLDOWN) + hass, dt_util.utcnow() + timedelta(seconds=ENTRY_RELOAD_COOLDOWN) ) await hass.async_block_till_done() @@ -394,7 +394,7 @@ async def test_rpc_update_entry_sleep_period( # Move time to generate sleep period update monkeypatch.setitem(mock_rpc_device.status["sys"], "wakeup_period", 3600) async_fire_time_changed( - hass, dt.utcnow() + timedelta(seconds=600 * SLEEP_PERIOD_MULTIPLIER) + hass, dt_util.utcnow() + timedelta(seconds=600 * SLEEP_PERIOD_MULTIPLIER) ) await hass.async_block_till_done() @@ -423,7 +423,7 @@ async def test_rpc_sleeping_device_no_periodic_updates( # Move time to generate polling async_fire_time_changed( - hass, dt.utcnow() + timedelta(seconds=SLEEP_PERIOD_MULTIPLIER * 1000) + hass, dt_util.utcnow() + timedelta(seconds=SLEEP_PERIOD_MULTIPLIER * 1000) ) await hass.async_block_till_done() @@ -449,7 +449,7 @@ async def test_rpc_reconnect_auth_error( # Move time to generate reconnect async_fire_time_changed( - hass, dt.utcnow() + timedelta(seconds=RPC_RECONNECT_INTERVAL) + hass, dt_util.utcnow() + timedelta(seconds=RPC_RECONNECT_INTERVAL) ) await hass.async_block_till_done() @@ -519,7 +519,7 @@ async def test_rpc_reconnect_error( # Move time to generate reconnect async_fire_time_changed( - hass, dt.utcnow() + timedelta(seconds=RPC_RECONNECT_INTERVAL) + hass, dt_util.utcnow() + timedelta(seconds=RPC_RECONNECT_INTERVAL) ) await hass.async_block_till_done() diff --git a/tests/components/shelly/test_utils.py b/tests/components/shelly/test_utils.py index 701e8b487cb7..1bf660deb2a4 100644 --- a/tests/components/shelly/test_utils.py +++ b/tests/components/shelly/test_utils.py @@ -11,7 +11,7 @@ from homeassistant.components.shelly.utils import ( get_rpc_input_triggers, is_block_momentary_input, ) -from homeassistant.util import dt +from homeassistant.util import dt as dt_util DEVICE_BLOCK_ID = 4 @@ -153,12 +153,12 @@ async def test_get_block_device_sleep_period(settings, sleep_period) -> None: async def test_get_device_uptime() -> None: """Test block test get device uptime.""" assert get_device_uptime( - 55, dt.as_utc(dt.parse_datetime("2019-01-10 18:42:00+00:00")) - ) == dt.as_utc(dt.parse_datetime("2019-01-10 18:42:00+00:00")) + 55, dt_util.as_utc(dt_util.parse_datetime("2019-01-10 18:42:00+00:00")) + ) == dt_util.as_utc(dt_util.parse_datetime("2019-01-10 18:42:00+00:00")) assert get_device_uptime( - 50, dt.as_utc(dt.parse_datetime("2019-01-10 18:42:00+00:00")) - ) == dt.as_utc(dt.parse_datetime("2019-01-10 18:42:10+00:00")) + 50, dt_util.as_utc(dt_util.parse_datetime("2019-01-10 18:42:00+00:00")) + ) == dt_util.as_utc(dt_util.parse_datetime("2019-01-10 18:42:10+00:00")) async def test_get_block_input_triggers(mock_block_device, monkeypatch) -> None: diff --git a/tests/components/smarttub/__init__.py b/tests/components/smarttub/__init__.py index b19af1ee59a9..f6abd4cb5d7c 100644 --- a/tests/components/smarttub/__init__.py +++ b/tests/components/smarttub/__init__.py @@ -3,13 +3,13 @@ from datetime import timedelta from homeassistant.components.smarttub.const import SCAN_INTERVAL -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed async def trigger_update(hass): """Trigger a polling update by moving time forward.""" - new_time = dt.utcnow() + timedelta(seconds=SCAN_INTERVAL + 1) + new_time = dt_util.utcnow() + timedelta(seconds=SCAN_INTERVAL + 1) async_fire_time_changed(hass, new_time) await hass.async_block_till_done() diff --git a/tests/components/sonos/test_switch.py b/tests/components/sonos/test_switch.py index 405d99f5a17a..301c4a641ea5 100644 --- a/tests/components/sonos/test_switch.py +++ b/tests/components/sonos/test_switch.py @@ -16,7 +16,7 @@ from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import ATTR_TIME, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .conftest import SonosMockEvent @@ -114,7 +114,7 @@ async def test_switch_attributes( with patch.object(hass.data[DATA_SONOS_DISCOVERY_MANAGER], "async_shutdown") as m: async_fire_time_changed( hass, - dt.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), ) await hass.async_block_till_done() assert m.called diff --git a/tests/components/soundtouch/test_media_player.py b/tests/components/soundtouch/test_media_player.py index c01742854b21..0bae58a1c00a 100644 --- a/tests/components/soundtouch/test_media_player.py +++ b/tests/components/soundtouch/test_media_player.py @@ -31,7 +31,7 @@ from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import STATE_OFF, STATE_PAUSED, STATE_PLAYING from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .conftest import DEVICE_1_ENTITY_ID, DEVICE_2_ENTITY_ID @@ -662,7 +662,7 @@ async def test_zone_attributes( # Fast-forward time to allow all entities to be set up and updated again async_fire_time_changed( hass, - dt.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), ) await hass.async_block_till_done() diff --git a/tests/components/sql/test_sensor.py b/tests/components/sql/test_sensor.py index cd123556daf8..a6aa18c92946 100644 --- a/tests/components/sql/test_sensor.py +++ b/tests/components/sql/test_sensor.py @@ -17,7 +17,7 @@ from homeassistant.const import CONF_UNIQUE_ID, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from . import ( YAML_CONFIG, @@ -218,7 +218,7 @@ async def test_invalid_url_on_update( await init_integration(hass, config) async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=1), + dt_util.utcnow() + timedelta(minutes=1), ) await hass.async_block_till_done() @@ -399,7 +399,7 @@ async def test_no_issue_when_view_has_the_text_entity_id_in_it( ) async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=1), + dt_util.utcnow() + timedelta(minutes=1), ) await hass.async_block_till_done() diff --git a/tests/components/switcher_kis/test_init.py b/tests/components/switcher_kis/test_init.py index 8006e81783af..6b592b250771 100644 --- a/tests/components/switcher_kis/test_init.py +++ b/tests/components/switcher_kis/test_init.py @@ -14,7 +14,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from homeassistant.util import dt, slugify +from homeassistant.util import dt as dt_util, slugify from . import init_integration from .consts import DUMMY_SWITCHER_DEVICES, YAML_CONFIG @@ -63,7 +63,7 @@ async def test_update_fail( assert len(hass.data[DOMAIN][DATA_DEVICE]) == 2 async_fire_time_changed( - hass, dt.utcnow() + timedelta(seconds=MAX_UPDATE_INTERVAL_SEC + 1) + hass, dt_util.utcnow() + timedelta(seconds=MAX_UPDATE_INTERVAL_SEC + 1) ) await hass.async_block_till_done() @@ -84,7 +84,7 @@ async def test_update_fail( mock_bridge.mock_callbacks(DUMMY_SWITCHER_DEVICES) await hass.async_block_till_done() async_fire_time_changed( - hass, dt.utcnow() + timedelta(seconds=MAX_UPDATE_INTERVAL_SEC - 1) + hass, dt_util.utcnow() + timedelta(seconds=MAX_UPDATE_INTERVAL_SEC - 1) ) for device in DUMMY_SWITCHER_DEVICES: From 24290e5d08ab6cffc93be7e2b462b0e581e4e8ad Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Mon, 29 May 2023 17:24:15 -0400 Subject: [PATCH 664/822] Add `datetime` platform (#81943) Co-authored-by: Franck Nijhof Co-authored-by: Franck Nijhof --- .core_files.yaml | 1 + CODEOWNERS | 2 + homeassistant/bootstrap.py | 8 +- homeassistant/components/datetime/__init__.py | 126 ++++++++++++++++++ homeassistant/components/datetime/const.py | 7 + .../components/datetime/manifest.json | 8 ++ .../components/datetime/services.yaml | 14 ++ .../components/datetime/strings.json | 8 ++ homeassistant/components/demo/__init__.py | 1 + homeassistant/components/demo/datetime.py | 77 +++++++++++ .../components/input_datetime/__init__.py | 2 +- homeassistant/const.py | 6 + tests/components/datetime/__init__.py | 1 + tests/components/datetime/test_init.py | 98 ++++++++++++++ tests/components/demo/test_datetime.py | 35 +++++ tests/components/input_datetime/test_init.py | 54 ++++---- .../custom_components/test/datetime.py | 50 +++++++ 17 files changed, 468 insertions(+), 30 deletions(-) create mode 100644 homeassistant/components/datetime/__init__.py create mode 100644 homeassistant/components/datetime/const.py create mode 100644 homeassistant/components/datetime/manifest.json create mode 100644 homeassistant/components/datetime/services.yaml create mode 100644 homeassistant/components/datetime/strings.json create mode 100644 homeassistant/components/demo/datetime.py create mode 100644 tests/components/datetime/__init__.py create mode 100644 tests/components/datetime/test_init.py create mode 100644 tests/components/demo/test_datetime.py create mode 100644 tests/testing_config/custom_components/test/datetime.py diff --git a/.core_files.yaml b/.core_files.yaml index 118346408f8f..9af81c599348 100644 --- a/.core_files.yaml +++ b/.core_files.yaml @@ -21,6 +21,7 @@ base_platforms: &base_platforms - homeassistant/components/climate/** - homeassistant/components/cover/** - homeassistant/components/date/** + - homeassistant/components/datetime/** - homeassistant/components/device_tracker/** - homeassistant/components/diagnostics/** - homeassistant/components/fan/** diff --git a/CODEOWNERS b/CODEOWNERS index 3ca8df7ec16b..44b7e4bce367 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -240,6 +240,8 @@ build.json @home-assistant/supervisor /tests/components/daikin/ @fredrike /homeassistant/components/date/ @home-assistant/core /tests/components/date/ @home-assistant/core +/homeassistant/components/datetime/ @home-assistant/core +/tests/components/datetime/ @home-assistant/core /homeassistant/components/debugpy/ @frenck /tests/components/debugpy/ @frenck /homeassistant/components/deconz/ @Kane610 diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 46bd4b5d881e..67b62da94d56 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -19,6 +19,7 @@ import yarl from . import config as conf_util, config_entries, core, loader from .components import http from .const import ( + FORMAT_DATETIME, REQUIRED_NEXT_PYTHON_HA_RELEASE, REQUIRED_NEXT_PYTHON_VER, SIGNAL_BOOTSTRAP_INTEGRATIONS, @@ -347,7 +348,6 @@ def async_enable_logging( fmt = ( "%(asctime)s.%(msecs)03d %(levelname)s (%(threadName)s) [%(name)s] %(message)s" ) - datefmt = "%Y-%m-%d %H:%M:%S" if not log_no_color: try: @@ -362,7 +362,7 @@ def async_enable_logging( logging.getLogger().handlers[0].setFormatter( ColoredFormatter( colorfmt, - datefmt=datefmt, + datefmt=FORMAT_DATETIME, reset=True, log_colors={ "DEBUG": "cyan", @@ -378,7 +378,7 @@ def async_enable_logging( # If the above initialization failed for any reason, setup the default # formatting. If the above succeeds, this will result in a no-op. - logging.basicConfig(format=fmt, datefmt=datefmt, level=logging.INFO) + logging.basicConfig(format=fmt, datefmt=FORMAT_DATETIME, level=logging.INFO) # Capture warnings.warn(...) and friends messages in logs. # The standard destination for them is stderr, which may end up unnoticed. @@ -435,7 +435,7 @@ def async_enable_logging( _LOGGER.error("Error rolling over log file: %s", err) err_handler.setLevel(logging.INFO if verbose else logging.WARNING) - err_handler.setFormatter(logging.Formatter(fmt, datefmt=datefmt)) + err_handler.setFormatter(logging.Formatter(fmt, datefmt=FORMAT_DATETIME)) logger = logging.getLogger("") logger.addHandler(err_handler) diff --git a/homeassistant/components/datetime/__init__.py b/homeassistant/components/datetime/__init__.py new file mode 100644 index 000000000000..fb67f4b1ffb6 --- /dev/null +++ b/homeassistant/components/datetime/__init__.py @@ -0,0 +1,126 @@ +"""Component to allow setting date/time as platforms.""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +import logging +from typing import final + +import voluptuous as vol + +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, +) +from homeassistant.helpers.entity import Entity, EntityDescription +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.typing import ConfigType +from homeassistant.util import dt as dt_util + +from .const import ATTR_DATETIME, DOMAIN, SERVICE_SET_VALUE + +SCAN_INTERVAL = timedelta(seconds=30) + +ENTITY_ID_FORMAT = DOMAIN + ".{}" + +_LOGGER = logging.getLogger(__name__) + +__all__ = ["ATTR_DATETIME", "DOMAIN", "DateTimeEntity", "DateTimeEntityDescription"] + + +async def _async_set_value(entity: DateTimeEntity, service_call: ServiceCall) -> None: + """Service call wrapper to set a new date/time.""" + value: datetime = service_call.data[ATTR_DATETIME] + if value.tzinfo is None: + value = value.replace( + tzinfo=dt_util.get_time_zone(entity.hass.config.time_zone) + ) + return await entity.async_set_value(value) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up Date/Time entities.""" + component = hass.data[DOMAIN] = EntityComponent[DateTimeEntity]( + _LOGGER, DOMAIN, hass, SCAN_INTERVAL + ) + await component.async_setup(config) + + component.async_register_entity_service( + SERVICE_SET_VALUE, + { + vol.Required(ATTR_DATETIME): cv.datetime, + **ENTITY_SERVICE_FIELDS, + }, + _async_set_value, + ) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a config entry.""" + component: EntityComponent[DateTimeEntity] = hass.data[DOMAIN] + return await component.async_setup_entry(entry) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + component: EntityComponent[DateTimeEntity] = hass.data[DOMAIN] + return await component.async_unload_entry(entry) + + +@dataclass +class DateTimeEntityDescription(EntityDescription): + """A class that describes date/time entities.""" + + +class DateTimeEntity(Entity): + """Representation of a Date/time entity.""" + + entity_description: DateTimeEntityDescription + _attr_device_class: None = None + _attr_state: None = None + _attr_native_value: datetime | None + + @property + @final + def device_class(self) -> None: + """Return entity device class.""" + return None + + @property + @final + def state_attributes(self) -> None: + """Return the state attributes.""" + return None + + @property + @final + def state(self) -> str | None: + """Return the entity state.""" + if (value := self.native_value) is None: + return None + if value.tzinfo is None: + raise ValueError( + f"Invalid datetime: {self.entity_id} provides state '{value}', " + "which is missing timezone information" + ) + + return value.astimezone(timezone.utc).isoformat(timespec="seconds") + + @property + def native_value(self) -> datetime | None: + """Return the value reported by the datetime.""" + return self._attr_native_value + + def set_value(self, value: datetime) -> None: + """Change the date/time.""" + raise NotImplementedError() + + async def async_set_value(self, value: datetime) -> None: + """Change the date/time.""" + await self.hass.async_add_executor_job(self.set_value, value) diff --git a/homeassistant/components/datetime/const.py b/homeassistant/components/datetime/const.py new file mode 100644 index 000000000000..f9a5c4e538cf --- /dev/null +++ b/homeassistant/components/datetime/const.py @@ -0,0 +1,7 @@ +"""Provides the constants needed for the component.""" + +DOMAIN = "datetime" + +ATTR_DATETIME = "datetime" + +SERVICE_SET_VALUE = "set_value" diff --git a/homeassistant/components/datetime/manifest.json b/homeassistant/components/datetime/manifest.json new file mode 100644 index 000000000000..469d9a8bd988 --- /dev/null +++ b/homeassistant/components/datetime/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "datetime", + "name": "Date/Time", + "codeowners": ["@home-assistant/core"], + "documentation": "https://www.home-assistant.io/integrations/datetime", + "integration_type": "entity", + "quality_scale": "internal" +} diff --git a/homeassistant/components/datetime/services.yaml b/homeassistant/components/datetime/services.yaml new file mode 100644 index 000000000000..b5cce19e88b7 --- /dev/null +++ b/homeassistant/components/datetime/services.yaml @@ -0,0 +1,14 @@ +set_value: + name: Set Date/Time + description: Set the date/time for a datetime entity. + target: + entity: + domain: datetime + fields: + datetime: + name: Date & Time + description: The date/time to set. The time zone of the Home Assistant instance is assumed. + required: true + example: "2022/11/01 22:15" + selector: + datetime: diff --git a/homeassistant/components/datetime/strings.json b/homeassistant/components/datetime/strings.json new file mode 100644 index 000000000000..3b97559018c7 --- /dev/null +++ b/homeassistant/components/datetime/strings.json @@ -0,0 +1,8 @@ +{ + "title": "Date/Time", + "entity_component": { + "_": { + "name": "[%key:component::datetime::title%]" + } + } +} diff --git a/homeassistant/components/demo/__init__.py b/homeassistant/components/demo/__init__.py index 3d271b54bc93..a84d7bf4f0b8 100644 --- a/homeassistant/components/demo/__init__.py +++ b/homeassistant/components/demo/__init__.py @@ -29,6 +29,7 @@ COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM = [ Platform.CLIMATE, Platform.COVER, Platform.DATE, + Platform.DATETIME, Platform.FAN, Platform.HUMIDIFIER, Platform.LIGHT, diff --git a/homeassistant/components/demo/datetime.py b/homeassistant/components/demo/datetime.py new file mode 100644 index 000000000000..88027f58b922 --- /dev/null +++ b/homeassistant/components/demo/datetime.py @@ -0,0 +1,77 @@ +"""Demo platform that offers a fake date/time entity.""" +from __future__ import annotations + +from datetime import datetime, timezone + +from homeassistant.components.datetime import DateTimeEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import DEVICE_DEFAULT_NAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType + +from . import DOMAIN + + +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up the Demo date/time entity.""" + async_add_entities( + [ + DemoDateTime( + "datetime", + "Date and Time", + datetime(2020, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "mdi:calendar-clock", + False, + ), + ] + ) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Demo config entry.""" + await async_setup_platform(hass, {}, async_add_entities) + + +class DemoDateTime(DateTimeEntity): + """Representation of a Demo date/time entity.""" + + _attr_should_poll = False + + def __init__( + self, + unique_id: str, + name: str, + state: datetime, + icon: str, + assumed_state: bool, + ) -> None: + """Initialize the Demo date/time entity.""" + self._attr_assumed_state = assumed_state + self._attr_icon = icon + self._attr_name = name or DEVICE_DEFAULT_NAME + self._attr_native_value = state + self._attr_unique_id = unique_id + + self._attr_device_info = DeviceInfo( + identifiers={ + # Serial numbers are unique identifiers within a specific domain + (DOMAIN, unique_id) + }, + name=self.name, + ) + + async def async_set_value(self, value: datetime) -> None: + """Update the date/time.""" + self._attr_native_value = value + self.async_write_ha_state() diff --git a/homeassistant/components/input_datetime/__init__.py b/homeassistant/components/input_datetime/__init__.py index c51c0fdd67c4..769b2d794d01 100644 --- a/homeassistant/components/input_datetime/__init__.py +++ b/homeassistant/components/input_datetime/__init__.py @@ -272,7 +272,7 @@ class InputDatetime(collection.CollectionEntity, RestoreEntity): if self.state is not None: return - default_value = py_datetime.datetime.today().strftime("%Y-%m-%d 00:00:00") + default_value = py_datetime.datetime.today().strftime(f"{FMT_DATE} 00:00:00") # Priority 2: Old state if (old_state := await self.async_get_last_state()) is None: diff --git a/homeassistant/const.py b/homeassistant/const.py index 46fc8b57568b..f150938347d6 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -32,6 +32,7 @@ class Platform(StrEnum): CLIMATE = "climate" COVER = "cover" DATE = "date" + DATETIME = "datetime" DEVICE_TRACKER = "device_tracker" FAN = "fan" GEO_LOCATION = "geo_location" @@ -1165,6 +1166,11 @@ HASSIO_USER_NAME = "Supervisor" SIGNAL_BOOTSTRAP_INTEGRATIONS = "bootstrap_integrations" +# Date/Time formats +FORMAT_DATE: Final = "%Y-%m-%d" +FORMAT_TIME: Final = "%H:%M:%S" +FORMAT_DATETIME: Final = f"{FORMAT_DATE} {FORMAT_TIME}" + class EntityCategory(StrEnum): """Category of an entity. diff --git a/tests/components/datetime/__init__.py b/tests/components/datetime/__init__.py new file mode 100644 index 000000000000..728e6e4a99dd --- /dev/null +++ b/tests/components/datetime/__init__.py @@ -0,0 +1 @@ +"""Tests for the datetime component.""" diff --git a/tests/components/datetime/test_init.py b/tests/components/datetime/test_init.py new file mode 100644 index 000000000000..66390c8d90f4 --- /dev/null +++ b/tests/components/datetime/test_init.py @@ -0,0 +1,98 @@ +"""The tests for the datetime component.""" +from datetime import datetime, timezone +from zoneinfo import ZoneInfo + +import pytest + +from homeassistant.components.datetime import ( + ATTR_DATETIME, + DOMAIN, + SERVICE_SET_VALUE, + DateTimeEntity, +) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, CONF_PLATFORM +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +DEFAULT_VALUE = datetime(2020, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + + +class MockDateTimeEntity(DateTimeEntity): + """Mock datetime device to use in tests.""" + + def __init__(self, native_value: datetime | None = DEFAULT_VALUE) -> None: + """Initialize mock datetime entity.""" + self._attr_native_value = native_value + + async def async_set_value(self, value: datetime) -> None: + """Change the date/time.""" + self._attr_native_value = value + + +async def test_datetime(hass: HomeAssistant, enable_custom_integrations: None) -> None: + """Test date/time entity.""" + hass.config.set_time_zone("UTC") + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() + + state = hass.states.get("datetime.test") + assert state.state == "2020-01-01T01:02:03+00:00" + assert state.attributes == {ATTR_FRIENDLY_NAME: "test"} + + # Test updating datetime + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + {ATTR_DATETIME: datetime(2022, 3, 3, 3, 4, 5), ATTR_ENTITY_ID: "datetime.test"}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("datetime.test") + assert state.state == "2022-03-03T03:04:05+00:00" + + # Test updating datetime with UTC timezone + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + {ATTR_DATETIME: "2022-03-03T03:04:05+00:00", ATTR_ENTITY_ID: "datetime.test"}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("datetime.test") + assert state.state == "2022-03-03T03:04:05+00:00" + + # Test updating datetime with non UTC timezone + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + {ATTR_DATETIME: "2022-03-03T03:04:05-05:00", ATTR_ENTITY_ID: "datetime.test"}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("datetime.test") + assert state.state == "2022-03-03T08:04:05+00:00" + + # Test that non UTC timezone gets converted to UTC + assert ( + MockDateTimeEntity( + native_value=datetime(2020, 1, 2, 3, 4, 5, tzinfo=ZoneInfo("US/Eastern")) + ).state + == "2020-01-02T08:04:05+00:00" + ) + + # Test None state + date_entity = MockDateTimeEntity(native_value=None) + assert date_entity.state is None + assert date_entity.state_attributes is None + + # Test that timezone is required to process state + with pytest.raises(ValueError): + assert MockDateTimeEntity( + native_value=datetime(2020, 1, 2, 3, 4, 5, tzinfo=None) + ).state diff --git a/tests/components/demo/test_datetime.py b/tests/components/demo/test_datetime.py new file mode 100644 index 000000000000..90019f46af5d --- /dev/null +++ b/tests/components/demo/test_datetime.py @@ -0,0 +1,35 @@ +"""The tests for the demo datetime component.""" +import pytest + +from homeassistant.components.datetime import ATTR_DATETIME, DOMAIN, SERVICE_SET_VALUE +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +ENTITY_DATETIME = "datetime.date_and_time" + + +@pytest.fixture(autouse=True) +async def setup_demo_datetime(hass: HomeAssistant) -> None: + """Initialize setup demo datetime.""" + assert await async_setup_component(hass, DOMAIN, {"datetime": {"platform": "demo"}}) + await hass.async_block_till_done() + + +def test_setup_params(hass: HomeAssistant) -> None: + """Test the initial parameters.""" + state = hass.states.get(ENTITY_DATETIME) + assert state.state == "2020-01-01T12:00:00+00:00" + + +async def test_set_datetime(hass: HomeAssistant) -> None: + """Test set datetime service.""" + hass.config.set_time_zone("UTC") + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: ENTITY_DATETIME, ATTR_DATETIME: "2021-02-03 01:02:03"}, + blocking=True, + ) + state = hass.states.get(ENTITY_DATETIME) + assert state.state == "2021-02-03T01:02:03+00:00" diff --git a/tests/components/input_datetime/test_init.py b/tests/components/input_datetime/test_init.py index 834da847b7a2..e9f9458611a9 100644 --- a/tests/components/input_datetime/test_init.py +++ b/tests/components/input_datetime/test_init.py @@ -19,12 +19,16 @@ from homeassistant.components.input_datetime import ( CONFIG_SCHEMA, DEFAULT_TIME, DOMAIN, - FMT_DATE, - FMT_DATETIME, - FMT_TIME, SERVICE_RELOAD, ) -from homeassistant.const import ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, ATTR_NAME +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_FRIENDLY_NAME, + ATTR_NAME, + FORMAT_DATE, + FORMAT_DATETIME, + FORMAT_TIME, +) from homeassistant.core import Context, CoreState, HomeAssistant, State from homeassistant.exceptions import Unauthorized from homeassistant.helpers import entity_registry as er @@ -136,7 +140,7 @@ async def test_set_datetime(hass: HomeAssistant) -> None: await async_set_date_and_time(hass, entity_id, dt_obj) state = hass.states.get(entity_id) - assert state.state == dt_obj.strftime(FMT_DATETIME) + assert state.state == dt_obj.strftime(FORMAT_DATETIME) assert state.attributes["has_time"] assert state.attributes["has_date"] @@ -164,7 +168,7 @@ async def test_set_datetime_2(hass: HomeAssistant) -> None: await async_set_datetime(hass, entity_id, dt_obj) state = hass.states.get(entity_id) - assert state.state == dt_obj.strftime(FMT_DATETIME) + assert state.state == dt_obj.strftime(FORMAT_DATETIME) assert state.attributes["has_time"] assert state.attributes["has_date"] @@ -192,7 +196,7 @@ async def test_set_datetime_3(hass: HomeAssistant) -> None: await async_set_timestamp(hass, entity_id, dt_util.as_utc(dt_obj).timestamp()) state = hass.states.get(entity_id) - assert state.state == dt_obj.strftime(FMT_DATETIME) + assert state.state == dt_obj.strftime(FORMAT_DATETIME) assert state.attributes["has_time"] assert state.attributes["has_date"] @@ -218,7 +222,7 @@ async def test_set_datetime_time(hass: HomeAssistant) -> None: await async_set_date_and_time(hass, entity_id, dt_obj) state = hass.states.get(entity_id) - assert state.state == dt_obj.strftime(FMT_TIME) + assert state.state == dt_obj.strftime(FORMAT_TIME) assert state.attributes["has_time"] assert not state.attributes["has_date"] @@ -337,7 +341,7 @@ async def test_restore_state(hass: HomeAssistant) -> None: "test_bogus_data": { "has_time": True, "has_date": True, - "initial": initial.strftime(FMT_DATETIME), + "initial": initial.strftime(FORMAT_DATETIME), }, "test_was_time": {"has_time": False, "has_date": True}, "test_was_date": {"has_time": True, "has_date": False}, @@ -347,22 +351,22 @@ async def test_restore_state(hass: HomeAssistant) -> None: dt_obj = datetime.datetime(2017, 9, 7, 19, 46) state_time = hass.states.get("input_datetime.test_time") - assert state_time.state == dt_obj.strftime(FMT_TIME) + assert state_time.state == dt_obj.strftime(FORMAT_TIME) state_date = hass.states.get("input_datetime.test_date") - assert state_date.state == dt_obj.strftime(FMT_DATE) + assert state_date.state == dt_obj.strftime(FORMAT_DATE) state_datetime = hass.states.get("input_datetime.test_datetime") - assert state_datetime.state == dt_obj.strftime(FMT_DATETIME) + assert state_datetime.state == dt_obj.strftime(FORMAT_DATETIME) state_bogus = hass.states.get("input_datetime.test_bogus_data") - assert state_bogus.state == initial.strftime(FMT_DATETIME) + assert state_bogus.state == initial.strftime(FORMAT_DATETIME) state_was_time = hass.states.get("input_datetime.test_was_time") - assert state_was_time.state == default.strftime(FMT_DATE) + assert state_was_time.state == default.strftime(FORMAT_DATE) state_was_date = hass.states.get("input_datetime.test_was_date") - assert state_was_date.state == default.strftime(FMT_TIME) + assert state_was_date.state == default.strftime(FORMAT_TIME) async def test_default_value(hass: HomeAssistant) -> None: @@ -381,15 +385,15 @@ async def test_default_value(hass: HomeAssistant) -> None: dt_obj = datetime.datetime.combine(datetime.date.today(), DEFAULT_TIME) state_time = hass.states.get("input_datetime.test_time") - assert state_time.state == dt_obj.strftime(FMT_TIME) + assert state_time.state == dt_obj.strftime(FORMAT_TIME) assert state_time.attributes.get("timestamp") is not None state_date = hass.states.get("input_datetime.test_date") - assert state_date.state == dt_obj.strftime(FMT_DATE) + assert state_date.state == dt_obj.strftime(FORMAT_DATE) assert state_date.attributes.get("timestamp") is not None state_datetime = hass.states.get("input_datetime.test_datetime") - assert state_datetime.state == dt_obj.strftime(FMT_DATETIME) + assert state_datetime.state == dt_obj.strftime(FORMAT_DATETIME) assert state_datetime.attributes.get("timestamp") is not None @@ -446,7 +450,7 @@ async def test_reload( assert state_1 is not None assert state_2 is None assert state_3 is not None - assert dt_obj.strftime(FMT_DATE) == state_1.state + assert dt_obj.strftime(FORMAT_DATE) == state_1.state assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "dt1") == f"{DOMAIN}.dt1" assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "dt2") is None assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "dt3") == f"{DOMAIN}.dt3" @@ -484,10 +488,10 @@ async def test_reload( assert state_1 is not None assert state_2 is not None assert state_3 is None - assert state_1.state == DEFAULT_TIME.strftime(FMT_TIME) + assert state_1.state == DEFAULT_TIME.strftime(FORMAT_TIME) assert state_2.state == datetime.datetime.combine( datetime.date.today(), DEFAULT_TIME - ).strftime(FMT_DATETIME) + ).strftime(FORMAT_DATETIME) assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "dt1") == f"{DOMAIN}.dt1" assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "dt2") == f"{DOMAIN}.dt2" @@ -705,7 +709,7 @@ async def test_timestamp(hass: HomeAssistant) -> None: assert ( dt_util.as_local( dt_util.utc_from_timestamp(state_with_tz.attributes[ATTR_TIMESTAMP]) - ).strftime(FMT_DATETIME) + ).strftime(FORMAT_DATETIME) == "2020-12-13 01:00:00" ) @@ -719,13 +723,13 @@ async def test_timestamp(hass: HomeAssistant) -> None: assert ( dt_util.utc_from_timestamp( state_without_tz.attributes[ATTR_TIMESTAMP] - ).strftime(FMT_DATETIME) + ).strftime(FORMAT_DATETIME) == "2020-12-13 18:00:00" ) assert ( dt_util.as_local( dt_util.utc_from_timestamp(state_without_tz.attributes[ATTR_TIMESTAMP]) - ).strftime(FMT_DATETIME) + ).strftime(FORMAT_DATETIME) == "2020-12-13 10:00:00" ) # Use datetime.datetime.fromtimestamp @@ -734,7 +738,7 @@ async def test_timestamp(hass: HomeAssistant) -> None: datetime.datetime.fromtimestamp( state_without_tz.attributes[ATTR_TIMESTAMP], datetime.timezone.utc ) - ).strftime(FMT_DATETIME) + ).strftime(FORMAT_DATETIME) == "2020-12-13 10:00:00" ) diff --git a/tests/testing_config/custom_components/test/datetime.py b/tests/testing_config/custom_components/test/datetime.py new file mode 100644 index 000000000000..7fca8d578814 --- /dev/null +++ b/tests/testing_config/custom_components/test/datetime.py @@ -0,0 +1,50 @@ +"""Provide a mock time platform. + +Call init before using it in your tests to ensure clean test data. +""" +from datetime import datetime, timezone + +from homeassistant.components.datetime import DateTimeEntity + +from tests.common import MockEntity + +UNIQUE_DATETIME = "unique_datetime" + +ENTITIES = [] + + +class MockDateTimeEntity(MockEntity, DateTimeEntity): + """Mock date/time class.""" + + @property + def native_value(self): + """Return the native value of this date/time.""" + return self._handle("native_value") + + def set_value(self, value: datetime) -> None: + """Change the time.""" + self._values["native_value"] = value + + +def init(empty=False): + """Initialize the platform with entities.""" + global ENTITIES + + ENTITIES = ( + [] + if empty + else [ + MockDateTimeEntity( + name="test", + unique_id=UNIQUE_DATETIME, + native_value=datetime(2020, 1, 1, 1, 2, 3, tzinfo=timezone.utc), + ), + ] + ) + + +async def async_setup_platform( + hass, config, async_add_entities_callback, discovery_info=None +): + """Return mock entities.""" + async_add_entities_callback(ENTITIES) From 52ef4a3b750546667a7969ee57f38189e4eb175c Mon Sep 17 00:00:00 2001 From: rikroe <42204099+rikroe@users.noreply.github.com> Date: Mon, 29 May 2023 23:28:06 +0200 Subject: [PATCH 665/822] Ensure state update after BMW remote service execution (#93745) Co-authored-by: rikroe --- .../components/bmw_connected_drive/button.py | 3 --- homeassistant/components/bmw_connected_drive/lock.py | 4 ++++ .../components/bmw_connected_drive/number.py | 2 ++ .../components/bmw_connected_drive/select.py | 2 ++ .../components/bmw_connected_drive/switch.py | 4 ++++ tests/components/bmw_connected_drive/conftest.py | 12 +++++++++++- tests/components/bmw_connected_drive/test_number.py | 9 +++++++++ tests/components/bmw_connected_drive/test_select.py | 7 +++++++ tests/components/bmw_connected_drive/test_switch.py | 8 ++++++++ 9 files changed, 47 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bmw_connected_drive/button.py b/homeassistant/components/bmw_connected_drive/button.py index 0ecc07357fed..5285820b32dd 100644 --- a/homeassistant/components/bmw_connected_drive/button.py +++ b/homeassistant/components/bmw_connected_drive/button.py @@ -122,7 +122,4 @@ class BMWButton(BMWBaseEntity, ButtonEntity): ) await self.entity_description.account_function(self.coordinator) - # Always update HA states after a button was executed. - # BMW remote services that change the vehicle's state update the local object - # when executing the service, so only the HA state machine needs further updates. self.coordinator.async_update_listeners() diff --git a/homeassistant/components/bmw_connected_drive/lock.py b/homeassistant/components/bmw_connected_drive/lock.py index ffc6cf6d8b73..d20ccd1fbb4b 100644 --- a/homeassistant/components/bmw_connected_drive/lock.py +++ b/homeassistant/components/bmw_connected_drive/lock.py @@ -68,6 +68,8 @@ class BMWLock(BMWBaseEntity, LockEntity): self.async_write_ha_state() await self.vehicle.remote_services.trigger_remote_door_lock() + self.coordinator.async_update_listeners() + async def async_unlock(self, **kwargs: Any) -> None: """Unlock the car.""" _LOGGER.debug("%s: unlocking doors", self.vehicle.name) @@ -79,6 +81,8 @@ class BMWLock(BMWBaseEntity, LockEntity): self.async_write_ha_state() await self.vehicle.remote_services.trigger_remote_door_unlock() + self.coordinator.async_update_listeners() + @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" diff --git a/homeassistant/components/bmw_connected_drive/number.py b/homeassistant/components/bmw_connected_drive/number.py index 820257f61632..c8f72b272c16 100644 --- a/homeassistant/components/bmw_connected_drive/number.py +++ b/homeassistant/components/bmw_connected_drive/number.py @@ -116,3 +116,5 @@ class BMWNumber(BMWBaseEntity, NumberEntity): await self.entity_description.remote_service(self.vehicle, value) except MyBMWAPIError as ex: raise HomeAssistantError(ex) from ex + + self.coordinator.async_update_listeners() diff --git a/homeassistant/components/bmw_connected_drive/select.py b/homeassistant/components/bmw_connected_drive/select.py index 52d35b477a2d..0b20ed908732 100644 --- a/homeassistant/components/bmw_connected_drive/select.py +++ b/homeassistant/components/bmw_connected_drive/select.py @@ -124,3 +124,5 @@ class BMWSelect(BMWBaseEntity, SelectEntity): option, ) await self.entity_description.remote_service(self.vehicle, option) + + self.coordinator.async_update_listeners() diff --git a/homeassistant/components/bmw_connected_drive/switch.py b/homeassistant/components/bmw_connected_drive/switch.py index af7a42b35b0a..0ef7d8801afd 100644 --- a/homeassistant/components/bmw_connected_drive/switch.py +++ b/homeassistant/components/bmw_connected_drive/switch.py @@ -101,9 +101,13 @@ class BMWSwitch(BMWBaseEntity, SwitchEntity): except MyBMWAPIError as ex: raise HomeAssistantError(ex) from ex + self.coordinator.async_update_listeners() + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" try: await self.entity_description.remote_service_off(self.vehicle) except MyBMWAPIError as ex: raise HomeAssistantError(ex) from ex + + self.coordinator.async_update_listeners() diff --git a/tests/components/bmw_connected_drive/conftest.py b/tests/components/bmw_connected_drive/conftest.py index 73e8f9a9b921..b65adb5b2c07 100644 --- a/tests/components/bmw_connected_drive/conftest.py +++ b/tests/components/bmw_connected_drive/conftest.py @@ -1,11 +1,15 @@ """Fixtures for BMW tests.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, Mock from bimmer_connected.api.authentication import MyBMWAuthentication from bimmer_connected.vehicle.remote_services import RemoteServices, RemoteServiceStatus import pytest +from homeassistant.components.bmw_connected_drive.coordinator import ( + BMWDataUpdateCoordinator, +) + from . import mock_login, mock_vehicles @@ -20,5 +24,11 @@ async def bmw_fixture(monkeypatch): AsyncMock(return_value=RemoteServiceStatus({"eventStatus": "EXECUTED"})), ) + monkeypatch.setattr( + BMWDataUpdateCoordinator, + "async_update_listeners", + Mock(), + ) + with mock_vehicles(): yield mock_vehicles diff --git a/tests/components/bmw_connected_drive/test_number.py b/tests/components/bmw_connected_drive/test_number.py index b6c16af3e03f..d8cd5d47867c 100644 --- a/tests/components/bmw_connected_drive/test_number.py +++ b/tests/components/bmw_connected_drive/test_number.py @@ -7,6 +7,9 @@ import pytest import respx from syrupy.assertion import SnapshotAssertion +from homeassistant.components.bmw_connected_drive.coordinator import ( + BMWDataUpdateCoordinator, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -43,6 +46,7 @@ async def test_update_triggers_success( # Setup component assert await setup_mocked_integration(hass) + BMWDataUpdateCoordinator.async_update_listeners.reset_mock() # Test await hass.services.async_call( @@ -53,6 +57,7 @@ async def test_update_triggers_success( target={"entity_id": entity_id}, ) assert RemoteServices.trigger_remote_service.call_count == 1 + assert BMWDataUpdateCoordinator.async_update_listeners.call_count == 1 @pytest.mark.parametrize( @@ -71,6 +76,7 @@ async def test_update_triggers_fail( # Setup component assert await setup_mocked_integration(hass) + BMWDataUpdateCoordinator.async_update_listeners.reset_mock() # Test with pytest.raises(ValueError): @@ -82,6 +88,7 @@ async def test_update_triggers_fail( target={"entity_id": entity_id}, ) assert RemoteServices.trigger_remote_service.call_count == 0 + assert BMWDataUpdateCoordinator.async_update_listeners.call_count == 0 @pytest.mark.parametrize( @@ -103,6 +110,7 @@ async def test_update_triggers_exceptions( # Setup component assert await setup_mocked_integration(hass) + BMWDataUpdateCoordinator.async_update_listeners.reset_mock() # Setup exception monkeypatch.setattr( @@ -121,3 +129,4 @@ async def test_update_triggers_exceptions( target={"entity_id": "number.i4_edrive40_target_soc"}, ) assert RemoteServices.trigger_remote_service.call_count == 1 + assert BMWDataUpdateCoordinator.async_update_listeners.call_count == 0 diff --git a/tests/components/bmw_connected_drive/test_select.py b/tests/components/bmw_connected_drive/test_select.py index bbef62b14eda..b5a13a13b63e 100644 --- a/tests/components/bmw_connected_drive/test_select.py +++ b/tests/components/bmw_connected_drive/test_select.py @@ -4,6 +4,9 @@ import pytest import respx from syrupy.assertion import SnapshotAssertion +from homeassistant.components.bmw_connected_drive.coordinator import ( + BMWDataUpdateCoordinator, +) from homeassistant.core import HomeAssistant from . import setup_mocked_integration @@ -41,6 +44,7 @@ async def test_update_triggers_success( # Setup component assert await setup_mocked_integration(hass) + BMWDataUpdateCoordinator.async_update_listeners.reset_mock() # Test await hass.services.async_call( @@ -51,6 +55,7 @@ async def test_update_triggers_success( target={"entity_id": entity_id}, ) assert RemoteServices.trigger_remote_service.call_count == 1 + assert BMWDataUpdateCoordinator.async_update_listeners.call_count == 1 @pytest.mark.parametrize( @@ -69,6 +74,7 @@ async def test_update_triggers_fail( # Setup component assert await setup_mocked_integration(hass) + BMWDataUpdateCoordinator.async_update_listeners.reset_mock() # Test with pytest.raises(ValueError): @@ -80,3 +86,4 @@ async def test_update_triggers_fail( target={"entity_id": entity_id}, ) assert RemoteServices.trigger_remote_service.call_count == 0 + assert BMWDataUpdateCoordinator.async_update_listeners.call_count == 0 diff --git a/tests/components/bmw_connected_drive/test_switch.py b/tests/components/bmw_connected_drive/test_switch.py index fd9871dfecde..06c01e19689c 100644 --- a/tests/components/bmw_connected_drive/test_switch.py +++ b/tests/components/bmw_connected_drive/test_switch.py @@ -7,6 +7,9 @@ import pytest import respx from syrupy.assertion import SnapshotAssertion +from homeassistant.components.bmw_connected_drive.coordinator import ( + BMWDataUpdateCoordinator, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -22,6 +25,7 @@ async def test_entity_state_attrs( # Setup component assert await setup_mocked_integration(hass) + BMWDataUpdateCoordinator.async_update_listeners.reset_mock() # Get all switch entities assert hass.states.async_all("switch") == snapshot @@ -44,6 +48,7 @@ async def test_update_triggers_success( # Setup component assert await setup_mocked_integration(hass) + BMWDataUpdateCoordinator.async_update_listeners.reset_mock() # Test await hass.services.async_call( @@ -53,6 +58,7 @@ async def test_update_triggers_success( target={"entity_id": entity_id}, ) assert RemoteServices.trigger_remote_service.call_count == 1 + assert BMWDataUpdateCoordinator.async_update_listeners.call_count == 1 @pytest.mark.parametrize( @@ -74,6 +80,7 @@ async def test_update_triggers_exceptions( # Setup component assert await setup_mocked_integration(hass) + BMWDataUpdateCoordinator.async_update_listeners.reset_mock() # Setup exception monkeypatch.setattr( @@ -98,3 +105,4 @@ async def test_update_triggers_exceptions( target={"entity_id": "switch.i4_edrive40_climate"}, ) assert RemoteServices.trigger_remote_service.call_count == 2 + assert BMWDataUpdateCoordinator.async_update_listeners.call_count == 0 From df6317f65e41c1210a743d6f6ad8315be2ec1f3b Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 29 May 2023 23:36:53 +0200 Subject: [PATCH 666/822] Add entity name translations to Buienradar (#91511) Co-authored-by: Erik Montnemery --- homeassistant/components/buienradar/const.py | 46 ++ homeassistant/components/buienradar/sensor.py | 261 ++++++---- .../components/buienradar/strings.json | 478 ++++++++++++++++++ 3 files changed, 676 insertions(+), 109 deletions(-) diff --git a/homeassistant/components/buienradar/const.py b/homeassistant/components/buienradar/const.py index 6af579dd74f6..8111f63c923f 100644 --- a/homeassistant/components/buienradar/const.py +++ b/homeassistant/components/buienradar/const.py @@ -18,3 +18,49 @@ DEFAULT_COUNTRY = "NL" SCHEDULE_OK = 10 """When an error occurred, new call after (minutes).""" SCHEDULE_NOK = 2 + +STATE_CONDITIONS = ["clear", "cloudy", "fog", "rainy", "snowy", "lightning"] + +STATE_DETAILED_CONDITIONS = [ + "clear", + "partlycloudy", + "partlycloudy-fog", + "partlycloudy-light-rain", + "partlycloudy-rain", + "cloudy", + "fog", + "rainy", + "light-rain", + "light-snow", + "partlycloudy-light-snow", + "partlycloudy-snow", + "partlycloudy-lightning", + "snowy", + "snowy-rainy", + "lightning", +] + +STATE_CONDITION_CODES = [ + "a", + "b", + "j", + "o", + "r", + "c", + "p", + "d", + "n", + "f", + "h", + "k", + "l", + "q", + "w", + "m", + "u", + "i", + "v", + "t", + "g", + "s", +] diff --git a/homeassistant/components/buienradar/sensor.py b/homeassistant/components/buienradar/sensor.py index 06b97cdedadb..b5c6e9cf32c6 100644 --- a/homeassistant/components/buienradar/sensor.py +++ b/homeassistant/components/buienradar/sensor.py @@ -48,7 +48,14 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util -from .const import CONF_TIMEFRAME, DEFAULT_TIMEFRAME, DOMAIN +from .const import ( + CONF_TIMEFRAME, + DEFAULT_TIMEFRAME, + DOMAIN, + STATE_CONDITION_CODES, + STATE_CONDITIONS, + STATE_DETAILED_CONDITIONS, +) from .util import BrData _LOGGER = logging.getLogger(__name__) @@ -67,584 +74,620 @@ STATIONNAME_LABEL = "Stationname" SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="stationname", - name=STATIONNAME_LABEL, + translation_key="stationname", ), # new in json api (>1.0.0): SensorEntityDescription( key="barometerfc", - name="Barometer value", + translation_key="barometerfc", icon="mdi:gauge", ), # new in json api (>1.0.0): SensorEntityDescription( key="barometerfcname", - name="Barometer", + translation_key="barometerfcname", icon="mdi:gauge", ), # new in json api (>1.0.0): SensorEntityDescription( key="barometerfcnamenl", - name="Barometer", + translation_key="barometerfcnamenl", icon="mdi:gauge", ), SensorEntityDescription( key="condition", - name="Condition", + translation_key="condition", + device_class=SensorDeviceClass.ENUM, + options=STATE_CONDITIONS, ), SensorEntityDescription( key="conditioncode", - name="Condition code", + translation_key="conditioncode", + device_class=SensorDeviceClass.ENUM, + options=STATE_CONDITION_CODES, ), SensorEntityDescription( key="conditiondetailed", - name="Detailed condition", + translation_key="conditiondetailed", + device_class=SensorDeviceClass.ENUM, + options=STATE_DETAILED_CONDITIONS, ), SensorEntityDescription( key="conditionexact", - name="Full condition", + translation_key="conditionexact", ), SensorEntityDescription( key="symbol", - name="Symbol", + translation_key="symbol", ), # new in json api (>1.0.0): SensorEntityDescription( key="feeltemperature", - name="Feel temperature", + translation_key="feeltemperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, ), SensorEntityDescription( key="humidity", - name="Humidity", + translation_key="humidity", native_unit_of_measurement=PERCENTAGE, icon="mdi:water-percent", state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="temperature", - name="Temperature", + translation_key="temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="groundtemperature", - name="Ground temperature", + translation_key="groundtemperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="windspeed", - name="Wind speed", + translation_key="windspeed", native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, device_class=SensorDeviceClass.WIND_SPEED, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="windforce", - name="Wind force", + translation_key="windforce", native_unit_of_measurement="Bft", icon="mdi:weather-windy", ), SensorEntityDescription( key="winddirection", - name="Wind direction", + translation_key="winddirection", icon="mdi:compass-outline", ), SensorEntityDescription( key="windazimuth", - name="Wind direction azimuth", + translation_key="windazimuth", native_unit_of_measurement=DEGREE, icon="mdi:compass-outline", ), SensorEntityDescription( key="pressure", - name="Pressure", + translation_key="pressure", native_unit_of_measurement=UnitOfPressure.HPA, icon="mdi:gauge", state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="visibility", - name="Visibility", + translation_key="visibility", native_unit_of_measurement=UnitOfLength.KILOMETERS, device_class=SensorDeviceClass.DISTANCE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="windgust", - name="Wind gust", + translation_key="windgust", native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, device_class=SensorDeviceClass.WIND_SPEED, ), SensorEntityDescription( key="precipitation", - name="Precipitation", + translation_key="precipitation", native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, ), SensorEntityDescription( key="irradiance", - name="Irradiance", + translation_key="irradiance", device_class=SensorDeviceClass.IRRADIANCE, native_unit_of_measurement=UnitOfIrradiance.WATTS_PER_SQUARE_METER, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="precipitation_forecast_average", - name="Precipitation forecast average", + translation_key="precipitation_forecast_average", native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, ), SensorEntityDescription( key="precipitation_forecast_total", - name="Precipitation forecast total", + translation_key="precipitation_forecast_total", native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, ), # new in json api (>1.0.0): SensorEntityDescription( key="rainlast24hour", - name="Rain last 24h", + translation_key="rainlast24hour", native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, ), # new in json api (>1.0.0): SensorEntityDescription( key="rainlasthour", - name="Rain last hour", + translation_key="rainlasthour", native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, ), SensorEntityDescription( key="temperature_1d", - name="Temperature 1d", + translation_key="temperature_1d", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, ), SensorEntityDescription( key="temperature_2d", - name="Temperature 2d", + translation_key="temperature_2d", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, ), SensorEntityDescription( key="temperature_3d", - name="Temperature 3d", + translation_key="temperature_3d", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, ), SensorEntityDescription( key="temperature_4d", - name="Temperature 4d", + translation_key="temperature_4d", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, ), SensorEntityDescription( key="temperature_5d", - name="Temperature 5d", + translation_key="temperature_5d", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, ), SensorEntityDescription( key="mintemp_1d", - name="Minimum temperature 1d", + translation_key="mintemp_1d", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, ), SensorEntityDescription( key="mintemp_2d", - name="Minimum temperature 2d", + translation_key="mintemp_2d", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, ), SensorEntityDescription( key="mintemp_3d", - name="Minimum temperature 3d", + translation_key="mintemp_3d", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, ), SensorEntityDescription( key="mintemp_4d", - name="Minimum temperature 4d", + translation_key="mintemp_4d", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, ), SensorEntityDescription( key="mintemp_5d", - name="Minimum temperature 5d", + translation_key="mintemp_5d", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, ), SensorEntityDescription( key="rain_1d", - name="Rain 1d", + translation_key="rain_1d", native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, ), SensorEntityDescription( key="rain_2d", - name="Rain 2d", + translation_key="rain_2d", native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, ), SensorEntityDescription( key="rain_3d", - name="Rain 3d", + translation_key="rain_3d", native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, ), SensorEntityDescription( key="rain_4d", - name="Rain 4d", + translation_key="rain_4d", native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, ), SensorEntityDescription( key="rain_5d", - name="Rain 5d", + translation_key="rain_5d", native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, ), # new in json api (>1.0.0): SensorEntityDescription( key="minrain_1d", - name="Minimum rain 1d", + translation_key="minrain_1d", native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, ), SensorEntityDescription( key="minrain_2d", - name="Minimum rain 2d", + translation_key="minrain_2d", native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, ), SensorEntityDescription( key="minrain_3d", - name="Minimum rain 3d", + translation_key="minrain_3d", native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, ), SensorEntityDescription( key="minrain_4d", - name="Minimum rain 4d", + translation_key="minrain_4d", native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, ), SensorEntityDescription( key="minrain_5d", - name="Minimum rain 5d", + translation_key="minrain_5d", native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, ), # new in json api (>1.0.0): SensorEntityDescription( key="maxrain_1d", - name="Maximum rain 1d", + translation_key="maxrain_1d", native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, ), SensorEntityDescription( key="maxrain_2d", - name="Maximum rain 2d", + translation_key="maxrain_2d", native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, ), SensorEntityDescription( key="maxrain_3d", - name="Maximum rain 3d", + translation_key="maxrain_3d", native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, ), SensorEntityDescription( key="maxrain_4d", - name="Maximum rain 4d", + translation_key="maxrain_4d", native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, ), SensorEntityDescription( key="maxrain_5d", - name="Maximum rain 5d", + translation_key="maxrain_5d", native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, ), SensorEntityDescription( key="rainchance_1d", - name="Rainchance 1d", + translation_key="rainchance_1d", native_unit_of_measurement=PERCENTAGE, icon="mdi:weather-pouring", ), SensorEntityDescription( key="rainchance_2d", - name="Rainchance 2d", + translation_key="rainchance_2d", native_unit_of_measurement=PERCENTAGE, icon="mdi:weather-pouring", ), SensorEntityDescription( key="rainchance_3d", - name="Rainchance 3d", + translation_key="rainchance_3d", native_unit_of_measurement=PERCENTAGE, icon="mdi:weather-pouring", ), SensorEntityDescription( key="rainchance_4d", - name="Rainchance 4d", + translation_key="rainchance_4d", native_unit_of_measurement=PERCENTAGE, icon="mdi:weather-pouring", ), SensorEntityDescription( key="rainchance_5d", - name="Rainchance 5d", + translation_key="rainchance_5d", native_unit_of_measurement=PERCENTAGE, icon="mdi:weather-pouring", ), SensorEntityDescription( key="sunchance_1d", - name="Sunchance 1d", + translation_key="sunchance_1d", native_unit_of_measurement=PERCENTAGE, icon="mdi:weather-partly-cloudy", ), SensorEntityDescription( key="sunchance_2d", - name="Sunchance 2d", + translation_key="sunchance_2d", native_unit_of_measurement=PERCENTAGE, icon="mdi:weather-partly-cloudy", ), SensorEntityDescription( key="sunchance_3d", - name="Sunchance 3d", + translation_key="sunchance_3d", native_unit_of_measurement=PERCENTAGE, icon="mdi:weather-partly-cloudy", ), SensorEntityDescription( key="sunchance_4d", - name="Sunchance 4d", + translation_key="sunchance_4d", native_unit_of_measurement=PERCENTAGE, icon="mdi:weather-partly-cloudy", ), SensorEntityDescription( key="sunchance_5d", - name="Sunchance 5d", + translation_key="sunchance_5d", native_unit_of_measurement=PERCENTAGE, icon="mdi:weather-partly-cloudy", ), SensorEntityDescription( key="windforce_1d", - name="Wind force 1d", + translation_key="windforce_1d", native_unit_of_measurement="Bft", icon="mdi:weather-windy", ), SensorEntityDescription( key="windforce_2d", - name="Wind force 2d", + translation_key="windforce_2d", native_unit_of_measurement="Bft", icon="mdi:weather-windy", ), SensorEntityDescription( key="windforce_3d", - name="Wind force 3d", + translation_key="windforce_3d", native_unit_of_measurement="Bft", icon="mdi:weather-windy", ), SensorEntityDescription( key="windforce_4d", - name="Wind force 4d", + translation_key="windforce_4d", native_unit_of_measurement="Bft", icon="mdi:weather-windy", ), SensorEntityDescription( key="windforce_5d", - name="Wind force 5d", + translation_key="windforce_5d", native_unit_of_measurement="Bft", icon="mdi:weather-windy", ), SensorEntityDescription( key="windspeed_1d", - name="Wind speed 1d", + translation_key="windspeed_1d", native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, device_class=SensorDeviceClass.WIND_SPEED, ), SensorEntityDescription( key="windspeed_2d", - name="Wind speed 2d", + translation_key="windspeed_2d", native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, device_class=SensorDeviceClass.WIND_SPEED, ), SensorEntityDescription( key="windspeed_3d", - name="Wind speed 3d", + translation_key="windspeed_3d", native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, device_class=SensorDeviceClass.WIND_SPEED, ), SensorEntityDescription( key="windspeed_4d", - name="Wind speed 4d", + translation_key="windspeed_4d", native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, device_class=SensorDeviceClass.WIND_SPEED, ), SensorEntityDescription( key="windspeed_5d", - name="Wind speed 5d", + translation_key="windspeed_5d", native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, device_class=SensorDeviceClass.WIND_SPEED, ), SensorEntityDescription( key="winddirection_1d", - name="Wind direction 1d", + translation_key="winddirection_1d", icon="mdi:compass-outline", ), SensorEntityDescription( key="winddirection_2d", - name="Wind direction 2d", + translation_key="winddirection_2d", icon="mdi:compass-outline", ), SensorEntityDescription( key="winddirection_3d", - name="Wind direction 3d", + translation_key="winddirection_3d", icon="mdi:compass-outline", ), SensorEntityDescription( key="winddirection_4d", - name="Wind direction 4d", + translation_key="winddirection_4d", icon="mdi:compass-outline", ), SensorEntityDescription( key="winddirection_5d", - name="Wind direction 5d", + translation_key="winddirection_5d", icon="mdi:compass-outline", ), SensorEntityDescription( key="windazimuth_1d", - name="Wind direction azimuth 1d", + translation_key="windazimuth_1d", native_unit_of_measurement=DEGREE, icon="mdi:compass-outline", ), SensorEntityDescription( key="windazimuth_2d", - name="Wind direction azimuth 2d", + translation_key="windazimuth_2d", native_unit_of_measurement=DEGREE, icon="mdi:compass-outline", ), SensorEntityDescription( key="windazimuth_3d", - name="Wind direction azimuth 3d", + translation_key="windazimuth_3d", native_unit_of_measurement=DEGREE, icon="mdi:compass-outline", ), SensorEntityDescription( key="windazimuth_4d", - name="Wind direction azimuth 4d", + translation_key="windazimuth_4d", native_unit_of_measurement=DEGREE, icon="mdi:compass-outline", ), SensorEntityDescription( key="windazimuth_5d", - name="Wind direction azimuth 5d", + translation_key="windazimuth_5d", native_unit_of_measurement=DEGREE, icon="mdi:compass-outline", ), SensorEntityDescription( key="condition_1d", - name="Condition 1d", + translation_key="condition_1d", + device_class=SensorDeviceClass.ENUM, + options=STATE_CONDITIONS, ), SensorEntityDescription( key="condition_2d", - name="Condition 2d", + translation_key="condition_2d", + device_class=SensorDeviceClass.ENUM, + options=STATE_CONDITIONS, ), SensorEntityDescription( key="condition_3d", - name="Condition 3d", + translation_key="condition_3d", + device_class=SensorDeviceClass.ENUM, + options=STATE_CONDITIONS, ), SensorEntityDescription( key="condition_4d", - name="Condition 4d", + translation_key="condition_4d", + device_class=SensorDeviceClass.ENUM, + options=STATE_CONDITIONS, ), SensorEntityDescription( key="condition_5d", - name="Condition 5d", + translation_key="condition_5d", + device_class=SensorDeviceClass.ENUM, + options=STATE_CONDITIONS, ), SensorEntityDescription( key="conditioncode_1d", - name="Condition code 1d", + translation_key="conditioncode_1d", + device_class=SensorDeviceClass.ENUM, + options=STATE_CONDITION_CODES, ), SensorEntityDescription( key="conditioncode_2d", - name="Condition code 2d", + translation_key="conditioncode_2d", + device_class=SensorDeviceClass.ENUM, + options=STATE_CONDITION_CODES, ), SensorEntityDescription( key="conditioncode_3d", - name="Condition code 3d", + translation_key="conditioncode_3d", + device_class=SensorDeviceClass.ENUM, + options=STATE_CONDITION_CODES, ), SensorEntityDescription( key="conditioncode_4d", - name="Condition code 4d", + translation_key="conditioncode_4d", + device_class=SensorDeviceClass.ENUM, + options=STATE_CONDITION_CODES, ), SensorEntityDescription( key="conditioncode_5d", - name="Condition code 5d", + translation_key="conditioncode_5d", + device_class=SensorDeviceClass.ENUM, + options=STATE_CONDITION_CODES, ), SensorEntityDescription( key="conditiondetailed_1d", - name="Detailed condition 1d", + translation_key="conditiondetailed_1d", + device_class=SensorDeviceClass.ENUM, + options=STATE_DETAILED_CONDITIONS, ), SensorEntityDescription( key="conditiondetailed_2d", - name="Detailed condition 2d", + translation_key="conditiondetailed_2d", + device_class=SensorDeviceClass.ENUM, + options=STATE_DETAILED_CONDITIONS, ), SensorEntityDescription( key="conditiondetailed_3d", - name="Detailed condition 3d", + translation_key="conditiondetailed_3d", + device_class=SensorDeviceClass.ENUM, + options=STATE_DETAILED_CONDITIONS, ), SensorEntityDescription( key="conditiondetailed_4d", - name="Detailed condition 4d", + translation_key="conditiondetailed_4d", + device_class=SensorDeviceClass.ENUM, + options=STATE_DETAILED_CONDITIONS, ), SensorEntityDescription( key="conditiondetailed_5d", - name="Detailed condition 5d", + translation_key="conditiondetailed_5d", + device_class=SensorDeviceClass.ENUM, + options=STATE_DETAILED_CONDITIONS, ), SensorEntityDescription( key="conditionexact_1d", - name="Full condition 1d", + translation_key="conditionexact_1d", ), SensorEntityDescription( key="conditionexact_2d", - name="Full condition 2d", + translation_key="conditionexact_2d", ), SensorEntityDescription( key="conditionexact_3d", - name="Full condition 3d", + translation_key="conditionexact_3d", ), SensorEntityDescription( key="conditionexact_4d", - name="Full condition 4d", + translation_key="conditionexact_4d", ), SensorEntityDescription( key="conditionexact_5d", - name="Full condition 5d", + translation_key="conditionexact_5d", ), SensorEntityDescription( key="symbol_1d", - name="Symbol 1d", + translation_key="symbol_1d", ), SensorEntityDescription( key="symbol_2d", - name="Symbol 2d", + translation_key="symbol_2d", ), SensorEntityDescription( key="symbol_3d", - name="Symbol 3d", + translation_key="symbol_3d", ), SensorEntityDescription( key="symbol_4d", - name="Symbol 4d", + translation_key="symbol_4d", ), SensorEntityDescription( key="symbol_5d", - name="Symbol 5d", + translation_key="symbol_5d", ), ) @@ -689,17 +732,17 @@ async def async_setup_entry( class BrSensor(SensorEntity): - """Representation of an Buienradar sensor.""" + """Representation of a Buienradar sensor.""" _attr_entity_registry_enabled_default = False _attr_should_poll = False + _attr_has_entity_name = True def __init__( self, client_name, coordinates, description: SensorEntityDescription ) -> None: """Initialize the sensor.""" self.entity_description = description - self._attr_name = f"{client_name} {description.name}" self._measured = None self._attr_unique_id = "{:2.6f}{:2.6f}{}".format( coordinates[CONF_LATITUDE], coordinates[CONF_LONGITUDE], description.key diff --git a/homeassistant/components/buienradar/strings.json b/homeassistant/components/buienradar/strings.json index 740068a952ba..d7af3b666885 100644 --- a/homeassistant/components/buienradar/strings.json +++ b/homeassistant/components/buienradar/strings.json @@ -25,5 +25,483 @@ } } } + }, + "entity": { + "sensor": { + "stationname": { + "name": "Station name" + }, + "barometerfc": { + "name": "Barometer value" + }, + "barometerfcname": { + "name": "Barometer" + }, + "barometerfcnamenl": { + "name": "Barometer" + }, + "condition": { + "name": "Condition", + "state": { + "clear": "Clear", + "cloudy": "[%key:component::weather::entity_component::_::state::cloudy%]", + "fog": "[%key:component::weather::entity_component::_::state::fog%]", + "rainy": "[%key:component::weather::entity_component::_::state::rainy%]", + "snowy": "[%key:component::weather::entity_component::_::state::snowy%]", + "lightning": "[%key:component::weather::entity_component::_::state::lightning%]" + } + }, + "conditioncode": { + "name": "Condition code" + }, + "conditiondetailed": { + "name": "Detailed condition", + "state": { + "clear": "Clear", + "partlycloudy": "[%key:component::weather::entity_component::_::state::partlycloudy%]", + "partlycloudy-fog": "Partly cloudy, fog", + "partlycloudy-light-rain": "Partly cloudy, light rain", + "partlycloudy-rain": "Partly cloudy, rain", + "cloudy": "[%key:component::weather::entity_component::_::state::cloudy%]", + "fog": "[%key:component::weather::entity_component::_::state::fog%]", + "rainy": "[%key:component::weather::entity_component::_::state::rainy%]", + "light-rain": "Light rain", + "light-snow": "Light snow", + "partlycloudy-light-snow": "Partly cloudy, light snow", + "partlycloudy-snow": "Partly cloudy, snow", + "partlycloudy-lightning": "Partly cloudy, lightning", + "snowy": "[%key:component::weather::entity_component::_::state::snowy%]", + "snowy-rainy": "[%key:component::weather::entity_component::_::state::snowy-rainy%]", + "lightning": "[%key:component::weather::entity_component::_::state::lightning%]" + } + }, + "conditionexact": { + "name": "Full condition" + }, + "symbol": { + "name": "Symbol" + }, + "feeltemperature": { + "name": "Feel temperature" + }, + "humidity": { + "name": "[%key:component::sensor::entity_component::humidity::name%]" + }, + "temperature": { + "name": "[%key:component::sensor::entity_component::temperature::name%]" + }, + "groundtemperature": { + "name": "Ground temperature" + }, + "windspeed": { + "name": "[%key:component::sensor::entity_component::wind_speed::name%]" + }, + "windforce": { + "name": "Wind force" + }, + "winddirection": { + "name": "Wind direction" + }, + "windazimuth": { + "name": "Wind direction azimuth" + }, + "pressure": { + "name": "[%key:component::sensor::entity_component::pressure::name%]" + }, + "visibility": { + "name": "[%key:component::weather::entity_component::_::state_attributes::visibility::name%]" + }, + "windgust": { + "name": "Wind gust" + }, + "precipitation": { + "name": "[%key:component::sensor::entity_component::precipitation::name%]" + }, + "irradiance": { + "name": "[%key:component::sensor::entity_component::irradiance::name%]" + }, + "precipitation_forecast_average": { + "name": "Precipitation forecast average" + }, + "precipitation_forecast_total": { + "name": "Precipitation forecast total" + }, + "rainlast24hour": { + "name": "Rain last 24h" + }, + "rainlasthour": { + "name": "Rain last hour" + }, + "temperature_1d": { + "name": "Temperature 1d" + }, + "temperature_2d": { + "name": "Temperature 2d" + }, + "temperature_3d": { + "name": "Temperature 3d" + }, + "temperature_4d": { + "name": "Temperature 4d" + }, + "temperature_5d": { + "name": "Temperature 5d" + }, + "mintemp_1d": { + "name": "Minimum temperature 1d" + }, + "mintemp_2d": { + "name": "Minimum temperature 2d" + }, + "mintemp_3d": { + "name": "Minimum temperature 3d" + }, + "mintemp_4d": { + "name": "Minimum temperature 4d" + }, + "mintemp_5d": { + "name": "Minimum temperature 5d" + }, + "rain_1d": { + "name": "Rain 1d" + }, + "rain_2d": { + "name": "Rain 2d" + }, + "rain_3d": { + "name": "Rain 3d" + }, + "rain_4d": { + "name": "Rain 4d" + }, + "rain_5d": { + "name": "Rain 5d" + }, + "minrain_1d": { + "name": "Minimum rain 1d" + }, + "minrain_2d": { + "name": "Minimum rain 2d" + }, + "minrain_3d": { + "name": "Minimum rain 3d" + }, + "minrain_4d": { + "name": "Minimum rain 4d" + }, + "minrain_5d": { + "name": "Minimum rain 5d" + }, + "maxrain_1d": { + "name": "Maximum rain 1d" + }, + "maxrain_2d": { + "name": "Maximum rain 2d" + }, + "maxrain_3d": { + "name": "Maximum rain 3d" + }, + "maxrain_4d": { + "name": "Maximum rain 4d" + }, + "maxrain_5d": { + "name": "Maximum rain 5d" + }, + "rainchance_1d": { + "name": "Rainchance 1d" + }, + "rainchance_2d": { + "name": "Rainchance 2d" + }, + "rainchance_3d": { + "name": "Rainchance 3d" + }, + "rainchance_4d": { + "name": "Rainchance 4d" + }, + "rainchance_5d": { + "name": "Rainchance 5d" + }, + "sunchance_1d": { + "name": "Sunchance 1d" + }, + "sunchance_2d": { + "name": "Sunchance 2d" + }, + "sunchance_3d": { + "name": "Sunchance 3d" + }, + "sunchance_4d": { + "name": "Sunchance 4d" + }, + "sunchance_5d": { + "name": "Sunchance 5d" + }, + "windforce_1d": { + "name": "Wind force 1d" + }, + "windforce_2d": { + "name": "Wind force 2d" + }, + "windforce_3d": { + "name": "Wind force 3d" + }, + "windforce_4d": { + "name": "Wind force 4d" + }, + "windforce_5d": { + "name": "Wind force 5d" + }, + "windspeed_1d": { + "name": "Wind speed 1d" + }, + "windspeed_2d": { + "name": "Wind speed 2d" + }, + "windspeed_3d": { + "name": "Wind speed 3d" + }, + "windspeed_4d": { + "name": "Wind speed 4d" + }, + "windspeed_5d": { + "name": "Wind speed 5d" + }, + "winddirection_1d": { + "name": "Wind direction 1d" + }, + "winddirection_2d": { + "name": "Wind direction 2d" + }, + "winddirection_3d": { + "name": "Wind direction 3d" + }, + "winddirection_4d": { + "name": "Wind direction 4d" + }, + "winddirection_5d": { + "name": "Wind direction 5d" + }, + "windazimuth_1d": { + "name": "Wind direction azimuth 1d" + }, + "windazimuth_2d": { + "name": "Wind direction azimuth 2d" + }, + "windazimuth_3d": { + "name": "Wind direction azimuth 3d" + }, + "windazimuth_4d": { + "name": "Wind direction azimuth 4d" + }, + "windazimuth_5d": { + "name": "Wind direction azimuth 5d" + }, + "condition_1d": { + "name": "Condition 1d", + "state": { + "clear": "[%key:component::buienradar::entity::sensor::condition::state::clear%]", + "cloudy": "[%key:component::buienradar::entity::sensor::condition::state::cloudy%]", + "fog": "[%key:component::buienradar::entity::sensor::condition::state::fog%]", + "rainy": "[%key:component::buienradar::entity::sensor::condition::state::rainy%]", + "snowy": "[%key:component::buienradar::entity::sensor::condition::state::snowy%]", + "lightning": "[%key:component::buienradar::entity::sensor::condition::state::lightning%]" + } + }, + "condition_2d": { + "name": "Condition 2d", + "state": { + "clear": "[%key:component::buienradar::entity::sensor::condition::state::clear%]", + "cloudy": "[%key:component::buienradar::entity::sensor::condition::state::cloudy%]", + "fog": "[%key:component::buienradar::entity::sensor::condition::state::fog%]", + "rainy": "[%key:component::buienradar::entity::sensor::condition::state::rainy%]", + "snowy": "[%key:component::buienradar::entity::sensor::condition::state::snowy%]", + "lightning": "[%key:component::buienradar::entity::sensor::condition::state::lightning%]" + } + }, + "condition_3d": { + "name": "Condition 3d", + "state": { + "clear": "[%key:component::buienradar::entity::sensor::condition::state::clear%]", + "cloudy": "[%key:component::buienradar::entity::sensor::condition::state::cloudy%]", + "fog": "[%key:component::buienradar::entity::sensor::condition::state::fog%]", + "rainy": "[%key:component::buienradar::entity::sensor::condition::state::rainy%]", + "snowy": "[%key:component::buienradar::entity::sensor::condition::state::snowy%]", + "lightning": "[%key:component::buienradar::entity::sensor::condition::state::lightning%]" + } + }, + "condition_4d": { + "name": "Condition 4d", + "state": { + "clear": "[%key:component::buienradar::entity::sensor::condition::state::clear%]", + "cloudy": "[%key:component::buienradar::entity::sensor::condition::state::cloudy%]", + "fog": "[%key:component::buienradar::entity::sensor::condition::state::fog%]", + "rainy": "[%key:component::buienradar::entity::sensor::condition::state::rainy%]", + "snowy": "[%key:component::buienradar::entity::sensor::condition::state::snowy%]", + "lightning": "[%key:component::buienradar::entity::sensor::condition::state::lightning%]" + } + }, + "condition_5d": { + "name": "Condition 5d", + "state": { + "clear": "[%key:component::buienradar::entity::sensor::condition::state::clear%]", + "cloudy": "[%key:component::buienradar::entity::sensor::condition::state::cloudy%]", + "fog": "[%key:component::buienradar::entity::sensor::condition::state::fog%]", + "rainy": "[%key:component::buienradar::entity::sensor::condition::state::rainy%]", + "snowy": "[%key:component::buienradar::entity::sensor::condition::state::snowy%]", + "lightning": "[%key:component::buienradar::entity::sensor::condition::state::lightning%]" + } + }, + "conditioncode_1d": { + "name": "Condition code 1d" + }, + "conditioncode_2d": { + "name": "Condition code 2d" + }, + "conditioncode_3d": { + "name": "Condition code 3d" + }, + "conditioncode_4d": { + "name": "Condition code 4d" + }, + "conditioncode_5d": { + "name": "Condition code 5d" + }, + "conditiondetailed_1d": { + "name": "Detailed condition 1d", + "state": { + "clear": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::clear%]", + "partlycloudy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy%]", + "partlycloudy-fog": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-fog%]", + "partlycloudy-light-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-light-rain%]", + "partlycloudy-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-rain%]", + "cloudy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::cloudy%]", + "fog": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::fog%]", + "rainy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::rainy%]", + "light-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::light-rain%]", + "light-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::light-snow%]", + "partlycloudy-light-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-light-snow%]", + "partlycloudy-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-snow%]", + "partlycloudy-lightning": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-lightning%]", + "snowy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::snowy%]", + "snowy-rainy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::snowy-rainy%]", + "lightning": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::lightning%]" + } + }, + "conditiondetailed_2d": { + "name": "Detailed condition 2d", + "state": { + "clear": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::clear%]", + "partlycloudy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy%]", + "partlycloudy-fog": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-fog%]", + "partlycloudy-light-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-light-rain%]", + "partlycloudy-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-rain%]", + "cloudy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::cloudy%]", + "fog": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::fog%]", + "rainy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::rainy%]", + "light-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::light-rain%]", + "light-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::light-snow%]", + "partlycloudy-light-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-light-snow%]", + "partlycloudy-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-snow%]", + "partlycloudy-lightning": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-lightning%]", + "snowy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::snowy%]", + "snowy-rainy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::snowy-rainy%]", + "lightning": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::lightning%]" + } + }, + "conditiondetailed_3d": { + "name": "Detailed condition 3d", + "state": { + "clear": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::clear%]", + "partlycloudy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy%]", + "partlycloudy-fog": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-fog%]", + "partlycloudy-light-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-light-rain%]", + "partlycloudy-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-rain%]", + "cloudy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::cloudy%]", + "fog": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::fog%]", + "rainy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::rainy%]", + "light-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::light-rain%]", + "light-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::light-snow%]", + "partlycloudy-light-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-light-snow%]", + "partlycloudy-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-snow%]", + "partlycloudy-lightning": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-lightning%]", + "snowy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::snowy%]", + "snowy-rainy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::snowy-rainy%]", + "lightning": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::lightning%]" + } + }, + "conditiondetailed_4d": { + "name": "Detailed condition 4d", + "state": { + "clear": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::clear%]", + "partlycloudy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy%]", + "partlycloudy-fog": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-fog%]", + "partlycloudy-light-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-light-rain%]", + "partlycloudy-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-rain%]", + "cloudy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::cloudy%]", + "fog": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::fog%]", + "rainy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::rainy%]", + "light-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::light-rain%]", + "light-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::light-snow%]", + "partlycloudy-light-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-light-snow%]", + "partlycloudy-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-snow%]", + "partlycloudy-lightning": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-lightning%]", + "snowy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::snowy%]", + "snowy-rainy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::snowy-rainy%]", + "lightning": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::lightning%]" + } + }, + "conditiondetailed_5d": { + "name": "Detailed condition 5d", + "state": { + "clear": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::clear%]", + "partlycloudy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy%]", + "partlycloudy-fog": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-fog%]", + "partlycloudy-light-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-light-rain%]", + "partlycloudy-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-rain%]", + "cloudy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::cloudy%]", + "fog": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::fog%]", + "rainy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::rainy%]", + "light-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::light-rain%]", + "light-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::light-snow%]", + "partlycloudy-light-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-light-snow%]", + "partlycloudy-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-snow%]", + "partlycloudy-lightning": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-lightning%]", + "snowy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::snowy%]", + "snowy-rainy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::snowy-rainy%]", + "lightning": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::lightning%]" + } + }, + "conditionexact_1d": { + "name": "Full condition 1d" + }, + "conditionexact_2d": { + "name": "Full condition 2d" + }, + "conditionexact_3d": { + "name": "Full condition 3d" + }, + "conditionexact_4d": { + "name": "Full condition 4d" + }, + "conditionexact_5d": { + "name": "Full condition 5d" + }, + "symbol_1d": { + "name": "Symbol 1d" + }, + "symbol_2d": { + "name": "Symbol 2d" + }, + "symbol_3d": { + "name": "Symbol 3d" + }, + "symbol_4d": { + "name": "Symbol 4d" + }, + "symbol_5d": { + "name": "Symbol 5d" + } + } } } From b063a542955e7da01aab8daeb3625ef030f31eed Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 29 May 2023 16:42:12 -0500 Subject: [PATCH 667/822] Reduce overhead to match homekit zeroconf discoveries (#93753) --- homeassistant/components/zeroconf/__init__.py | 83 +++++++++++++------ 1 file changed, 57 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index 9d7dd73be018..f12752dc5c3d 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -55,6 +55,7 @@ HOMEKIT_TYPES = [ # Thread based devices "_hap._udp.local.", ] +_HOMEKIT_MODEL_SPLITS = (None, " ", "-") # Top level keys we support matching against in properties that are always matched in # lower case. ex: ZeroconfServiceInfo.name @@ -66,7 +67,8 @@ DEFAULT_DEFAULT_INTERFACE = True DEFAULT_IPV6 = True HOMEKIT_PAIRED_STATUS_FLAG = "sf" -HOMEKIT_MODEL = "md" +HOMEKIT_MODEL_LOWER = "md" +HOMEKIT_MODEL_UPPER = "MD" # Property key=value has a max length of 255 # so we use 230 to leave space for key= @@ -192,7 +194,17 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: zeroconf_types, homekit_models = await asyncio.gather( async_get_zeroconf(hass), async_get_homekit(hass) ) - discovery = ZeroconfDiscovery(hass, zeroconf, zeroconf_types, homekit_models, ipv6) + homekit_model_lookup, homekit_model_matchers = _build_homekit_model_lookups( + homekit_models + ) + discovery = ZeroconfDiscovery( + hass, + zeroconf, + zeroconf_types, + homekit_model_lookup, + homekit_model_matchers, + ipv6, + ) await discovery.async_setup() async def _async_zeroconf_hass_start(hass: HomeAssistant, comp: str) -> None: @@ -212,6 +224,25 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True +def _build_homekit_model_lookups( + homekit_models: dict[str, HomeKitDiscoveredIntegration] +) -> tuple[ + dict[str, HomeKitDiscoveredIntegration], + dict[re.Pattern, HomeKitDiscoveredIntegration], +]: + """Build lookups for homekit models.""" + homekit_model_lookup: dict[str, HomeKitDiscoveredIntegration] = {} + homekit_model_matchers: dict[re.Pattern, HomeKitDiscoveredIntegration] = {} + + for model, discovery in homekit_models.items(): + if "*" in model or "?" in model or "[" in model: + homekit_model_matchers[_compile_fnmatch(model)] = discovery + else: + homekit_model_lookup[model] = discovery + + return homekit_model_lookup, homekit_model_matchers + + def _get_announced_addresses( adapters: list[Adapter], first_ip: bytes | None = None, @@ -347,14 +378,17 @@ class ZeroconfDiscovery: hass: HomeAssistant, zeroconf: HaZeroconf, zeroconf_types: dict[str, list[dict[str, str | dict[str, str]]]], - homekit_models: dict[str, HomeKitDiscoveredIntegration], + homekit_model_lookups: dict[str, HomeKitDiscoveredIntegration], + homekit_model_matchers: dict[re.Pattern, HomeKitDiscoveredIntegration], ipv6: bool, ) -> None: """Init discovery.""" self.hass = hass self.zeroconf = zeroconf self.zeroconf_types = zeroconf_types - self.homekit_models = homekit_models + self.homekit_model_lookups = homekit_model_lookups + self.homekit_model_matchers = homekit_model_matchers + self.ipv6 = ipv6 self.async_service_browser: HaAsyncServiceBrowser | None = None @@ -450,14 +484,14 @@ class ZeroconfDiscovery: # If we can handle it as a HomeKit discovery, we do that here. if service_type in HOMEKIT_TYPES and ( - homekit_model := async_get_homekit_discovery_domain( - self.homekit_models, props + homekit_discovery := async_get_homekit_discovery( + self.homekit_model_lookups, self.homekit_model_matchers, props ) ): - domain = homekit_model.domain + domain = homekit_discovery.domain discovery_flow.async_create_flow( self.hass, - homekit_model.domain, + homekit_discovery.domain, {"source": config_entries.SOURCE_HOMEKIT}, info, ) @@ -468,7 +502,7 @@ class ZeroconfDiscovery: # We only send updates to homekit_controller # if the device is already paired in order to avoid # offering a second discovery for the same device - if not is_homekit_paired(props) and not homekit_model.always_discover: + if not is_homekit_paired(props) and not homekit_discovery.always_discover: # If the device is paired with HomeKit we must send on # the update to homekit_controller so it can see when # the 'c#' field is updated. This is used to detect @@ -513,31 +547,28 @@ class ZeroconfDiscovery: ) -def async_get_homekit_discovery_domain( - homekit_models: dict[str, HomeKitDiscoveredIntegration], props: dict[str, Any] +def async_get_homekit_discovery( + homekit_model_lookups: dict[str, HomeKitDiscoveredIntegration], + homekit_model_matchers: dict[re.Pattern, HomeKitDiscoveredIntegration], + props: dict[str, Any], ) -> HomeKitDiscoveredIntegration | None: """Handle a HomeKit discovery. Return the domain to forward the discovery data to """ - model = None - for key in props: - if key.lower() == HOMEKIT_MODEL: - model = props[key] - break - - if model is None: + if not (model := props.get(HOMEKIT_MODEL_LOWER) or props.get(HOMEKIT_MODEL_UPPER)): return None - for test_model in homekit_models: - if ( - model != test_model - and not model.startswith((f"{test_model} ", f"{test_model}-")) - and not _memorized_fnmatch(model, test_model) - ): - continue + assert isinstance(model, str) - return homekit_models[test_model] + 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): + return discovery + + for pattern, discovery in homekit_model_matchers.items(): + if pattern.match(model): + return discovery return None From 07dd34b04a0cece46f25b6e620491ecc6ec34edf Mon Sep 17 00:00:00 2001 From: mover85 Date: Tue, 30 May 2023 09:44:05 +1200 Subject: [PATCH 668/822] Bump pydaikin 2.9.1 (#93635) --- homeassistant/components/daikin/manifest.json | 2 +- homeassistant/components/daikin/switch.py | 6 +++--- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/daikin/manifest.json b/homeassistant/components/daikin/manifest.json index 6f90b0cf5efa..02a8cdbe68fe 100644 --- a/homeassistant/components/daikin/manifest.json +++ b/homeassistant/components/daikin/manifest.json @@ -7,6 +7,6 @@ "iot_class": "local_polling", "loggers": ["pydaikin"], "quality_scale": "platinum", - "requirements": ["pydaikin==2.9.0"], + "requirements": ["pydaikin==2.9.1"], "zeroconf": ["_dkapi._tcp.local."] } diff --git a/homeassistant/components/daikin/switch.py b/homeassistant/components/daikin/switch.py index 68cd4fdc590d..1b83f7f7330f 100644 --- a/homeassistant/components/daikin/switch.py +++ b/homeassistant/components/daikin/switch.py @@ -42,7 +42,7 @@ async def async_setup_entry( [ DaikinZoneSwitch(daikin_api, zone_id) for zone_id, zone in enumerate(zones) - if zone != ("-", "0") + if zone[0] != ("-", "0") ] ) if daikin_api.device.support_advanced_modes: @@ -90,11 +90,11 @@ class DaikinZoneSwitch(SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the zone on.""" - await self._api.device.set_zone(self._zone_id, "1") + await self._api.device.set_zone(self._zone_id, "zone_onoff", "1") async def async_turn_off(self, **kwargs: Any) -> None: """Turn the zone off.""" - await self._api.device.set_zone(self._zone_id, "0") + await self._api.device.set_zone(self._zone_id, "zone_onoff", "0") class DaikinStreamerSwitch(SwitchEntity): diff --git a/requirements_all.txt b/requirements_all.txt index 5f218c1a7ac4..fa680bf6df9d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1576,7 +1576,7 @@ pycsspeechtts==1.0.8 # pycups==1.9.73 # homeassistant.components.daikin -pydaikin==2.9.0 +pydaikin==2.9.1 # homeassistant.components.danfoss_air pydanfossair==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 85dfcf824c61..4e3847687c6a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1164,7 +1164,7 @@ pycoolmasternet-async==0.1.5 pycsspeechtts==1.0.8 # homeassistant.components.daikin -pydaikin==2.9.0 +pydaikin==2.9.1 # homeassistant.components.deconz pydeconz==112 From 1ea202a5bc467291ce626b57a20db40143aa91b3 Mon Sep 17 00:00:00 2001 From: rikroe <42204099+rikroe@users.noreply.github.com> Date: Mon, 29 May 2023 23:48:13 +0200 Subject: [PATCH 669/822] Add charging switch to BMW Connected Drive (#93737) Co-authored-by: rikroe --- .../components/bmw_connected_drive/switch.py | 19 ++++++ ...s_v4_vehicles_state_WBA00000000DEMO02.json | 10 ++- .../snapshots/test_diagnostics.ambr | 64 +++++++++++++++---- .../snapshots/test_switch.ambr | 12 ++++ .../bmw_connected_drive/test_switch.py | 2 + 5 files changed, 92 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/bmw_connected_drive/switch.py b/homeassistant/components/bmw_connected_drive/switch.py index 0ef7d8801afd..41243ca93239 100644 --- a/homeassistant/components/bmw_connected_drive/switch.py +++ b/homeassistant/components/bmw_connected_drive/switch.py @@ -7,6 +7,7 @@ from typing import Any from bimmer_connected.models import MyBMWAPIError from bimmer_connected.vehicle import MyBMWVehicle +from bimmer_connected.vehicle.fuel_and_battery import ChargingState from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry @@ -38,6 +39,15 @@ class BMWSwitchEntityDescription(SwitchEntityDescription, BMWRequiredKeysMixin): dynamic_options: Callable[[MyBMWVehicle], list[str]] | None = None +CHARGING_STATE_ON = { + ChargingState.CHARGING, + ChargingState.COMPLETE, + ChargingState.FULLY_CHARGED, + ChargingState.FINISHED_FULLY_CHARGED, + ChargingState.FINISHED_NOT_FULL, + ChargingState.TARGET_REACHED, +} + NUMBER_TYPES: list[BMWSwitchEntityDescription] = [ BMWSwitchEntityDescription( key="climate", @@ -48,6 +58,15 @@ NUMBER_TYPES: list[BMWSwitchEntityDescription] = [ remote_service_off=lambda v: v.remote_services.trigger_remote_air_conditioning_stop(), icon="mdi:fan", ), + BMWSwitchEntityDescription( + key="charging", + name="Charging", + is_available=lambda v: v.is_remote_charge_stop_enabled, + value_fn=lambda v: v.fuel_and_battery.charging_status in CHARGING_STATE_ON, + remote_service_on=lambda v: v.remote_services.trigger_charge_start(), + remote_service_off=lambda v: v.remote_services.trigger_charge_stop(), + icon="mdi:ev-station", + ), ] diff --git a/tests/components/bmw_connected_drive/fixtures/vehicles/G26/bmw-eadrax-vcs_v4_vehicles_state_WBA00000000DEMO02.json b/tests/components/bmw_connected_drive/fixtures/vehicles/G26/bmw-eadrax-vcs_v4_vehicles_state_WBA00000000DEMO02.json index 8a0be88edfe8..a09748542953 100644 --- a/tests/components/bmw_connected_drive/fixtures/vehicles/G26/bmw-eadrax-vcs_v4_vehicles_state_WBA00000000DEMO02.json +++ b/tests/components/bmw_connected_drive/fixtures/vehicles/G26/bmw-eadrax-vcs_v4_vehicles_state_WBA00000000DEMO02.json @@ -43,7 +43,11 @@ "lights": true, "lock": true, "remote360": true, - "remoteChargingCommands": {}, + "remoteChargingCommands": { + "chargingControl": ["START", "STOP"], + "flapControl": ["NOT_SUPPORTED"], + "plugControl": ["NOT_SUPPORTED"] + }, "remoteSoftwareUpgrade": true, "sendPoi": true, "specialThemeSupport": [], @@ -159,9 +163,9 @@ "electricChargingState": { "chargingConnectionType": "UNKNOWN", "chargingLevelPercent": 80, - "chargingStatus": "INVALID", + "chargingStatus": "CHARGING", "chargingTarget": 80, - "isChargerConnected": false, + "isChargerConnected": true, "range": 472, "remainingChargingMinutes": 10 }, diff --git a/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr b/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr index f5966afb32eb..5befe3f0dcfe 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr @@ -289,6 +289,16 @@ 'lock': True, 'remote360': True, 'remoteChargingCommands': dict({ + 'chargingControl': list([ + 'START', + 'STOP', + ]), + 'flapControl': list([ + 'NOT_SUPPORTED', + ]), + 'plugControl': list([ + 'NOT_SUPPORTED', + ]), }), 'remoteSoftwareUpgrade': True, 'sendPoi': True, @@ -524,9 +534,9 @@ 'electricChargingState': dict({ 'chargingConnectionType': 'UNKNOWN', 'chargingLevelPercent': 80, - 'chargingStatus': 'INVALID', + 'chargingStatus': 'CHARGING', 'chargingTarget': 80, - 'isChargerConnected': False, + 'isChargerConnected': True, 'range': 472, 'remainingChargingMinutes': 10, }), @@ -778,9 +788,9 @@ 'charging_end_time': '2022-07-10T11:10:00+00:00', 'charging_start_time': None, 'charging_start_time_no_tz': None, - 'charging_status': 'NOT_CHARGING', + 'charging_status': 'CHARGING', 'charging_target': 80, - 'is_charger_connected': False, + 'is_charger_connected': True, 'remaining_battery_percent': 80, 'remaining_fuel': list([ None, @@ -804,8 +814,8 @@ 'has_electric_drivetrain': True, 'is_charging_plan_supported': True, 'is_lsc_enabled': True, - 'is_remote_charge_start_enabled': False, - 'is_remote_charge_stop_enabled': False, + 'is_remote_charge_start_enabled': True, + 'is_remote_charge_stop_enabled': True, 'is_remote_climate_start_enabled': True, 'is_remote_climate_stop_enabled': True, 'is_remote_horn_enabled': True, @@ -1687,6 +1697,16 @@ 'lock': True, 'remote360': True, 'remoteChargingCommands': dict({ + 'chargingControl': list([ + 'START', + 'STOP', + ]), + 'flapControl': list([ + 'NOT_SUPPORTED', + ]), + 'plugControl': list([ + 'NOT_SUPPORTED', + ]), }), 'remoteSoftwareUpgrade': True, 'sendPoi': True, @@ -1812,9 +1832,9 @@ 'electricChargingState': dict({ 'chargingConnectionType': 'UNKNOWN', 'chargingLevelPercent': 80, - 'chargingStatus': 'INVALID', + 'chargingStatus': 'CHARGING', 'chargingTarget': 80, - 'isChargerConnected': False, + 'isChargerConnected': True, 'range': 472, 'remainingChargingMinutes': 10, }), @@ -3191,6 +3211,16 @@ 'lock': True, 'remote360': True, 'remoteChargingCommands': dict({ + 'chargingControl': list([ + 'START', + 'STOP', + ]), + 'flapControl': list([ + 'NOT_SUPPORTED', + ]), + 'plugControl': list([ + 'NOT_SUPPORTED', + ]), }), 'remoteSoftwareUpgrade': True, 'sendPoi': True, @@ -3316,9 +3346,9 @@ 'electricChargingState': dict({ 'chargingConnectionType': 'UNKNOWN', 'chargingLevelPercent': 80, - 'chargingStatus': 'INVALID', + 'chargingStatus': 'CHARGING', 'chargingTarget': 80, - 'isChargerConnected': False, + 'isChargerConnected': True, 'range': 472, 'remainingChargingMinutes': 10, }), @@ -4024,6 +4054,16 @@ 'lock': True, 'remote360': True, 'remoteChargingCommands': dict({ + 'chargingControl': list([ + 'START', + 'STOP', + ]), + 'flapControl': list([ + 'NOT_SUPPORTED', + ]), + 'plugControl': list([ + 'NOT_SUPPORTED', + ]), }), 'remoteSoftwareUpgrade': True, 'sendPoi': True, @@ -4149,9 +4189,9 @@ 'electricChargingState': dict({ 'chargingConnectionType': 'UNKNOWN', 'chargingLevelPercent': 80, - 'chargingStatus': 'INVALID', + 'chargingStatus': 'CHARGING', 'chargingTarget': 80, - 'isChargerConnected': False, + 'isChargerConnected': True, 'range': 472, 'remainingChargingMinutes': 10, }), diff --git a/tests/components/bmw_connected_drive/snapshots/test_switch.ambr b/tests/components/bmw_connected_drive/snapshots/test_switch.ambr index 23d7ec2e8335..de5a44637c38 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_switch.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_switch.ambr @@ -13,5 +13,17 @@ 'last_updated': , 'state': 'off', }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i4 eDrive40 Charging', + 'icon': 'mdi:ev-station', + }), + 'context': , + 'entity_id': 'switch.i4_edrive40_charging', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }), ]) # --- diff --git a/tests/components/bmw_connected_drive/test_switch.py b/tests/components/bmw_connected_drive/test_switch.py index 06c01e19689c..26de4d3b6e8c 100644 --- a/tests/components/bmw_connected_drive/test_switch.py +++ b/tests/components/bmw_connected_drive/test_switch.py @@ -36,6 +36,8 @@ async def test_entity_state_attrs( [ ("switch.i4_edrive40_climate", "ON"), ("switch.i4_edrive40_climate", "OFF"), + ("switch.i4_edrive40_charging", "ON"), + ("switch.i4_edrive40_charging", "OFF"), ], ) async def test_update_triggers_success( From 53fe74e05524bc5e19e5f8fac7a2616d8947e4ce Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 29 May 2023 19:58:51 -0500 Subject: [PATCH 670/822] Reduce overhead to fetch integrations (#93767) We call this path over and over during startup and most of the time the integration is already loaded. We want that case to be the short path --- homeassistant/loader.py | 39 +++++++++++++++++++++------------------ 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/homeassistant/loader.py b/homeassistant/loader.py index e7658c44567c..6a8131d2454e 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -891,14 +891,15 @@ async def async_get_integrations( results: dict[str, Integration | Exception] = {} needed: dict[str, asyncio.Future[None]] = {} in_progress: dict[str, asyncio.Future[None]] = {} + if TYPE_CHECKING: + cache = cast(dict[str, Integration | asyncio.Future[None]], cache) for domain in domains: - int_or_fut: Integration | asyncio.Future[None] | None = cache.get( - domain, _UNDEF - ) - if isinstance(int_or_fut, asyncio.Future): - in_progress[domain] = int_or_fut + int_or_fut = cache.get(domain, _UNDEF) + # Integration is never subclassed, so we can check for type + if type(int_or_fut) is Integration: # pylint: disable=unidiomatic-typecheck + results[domain] = int_or_fut elif int_or_fut is not _UNDEF: - results[domain] = cast(Integration, int_or_fut) + in_progress[domain] = cast(asyncio.Future[None], int_or_fut) elif "." in domain: results[domain] = ValueError(f"Invalid domain {domain}") else: @@ -915,19 +916,21 @@ async def async_get_integrations( else: results[domain] = cast(Integration, int_or_fut) - # First we look for custom components - if needed: - # Instead of using resolve_from_root we use the cache of custom - # components to find the integration. - custom = await async_get_custom_components(hass) - for domain, future in needed.items(): - if integration := custom.get(domain): - results[domain] = cache[domain] = integration - future.set_result(None) + if not needed: + return results - for domain in results: - if domain in needed: - del needed[domain] + # First we look for custom components + # Instead of using resolve_from_root we use the cache of custom + # components to find the integration. + custom = await async_get_custom_components(hass) + for domain, future in needed.items(): + if integration := custom.get(domain): + results[domain] = cache[domain] = integration + future.set_result(None) + + for domain in results: + if domain in needed: + del needed[domain] # Now the rest use resolve_from_root if needed: From 493d78f070e60421d06165645bd0a06e95ad3c10 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 29 May 2023 19:59:15 -0500 Subject: [PATCH 671/822] Avoid dict comp in statistic_during_period if there is no conversion (#93699) --- homeassistant/components/recorder/statistics.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 7dbe29cea7d4..ee9662a21570 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -1478,7 +1478,9 @@ def statistic_during_period( state_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) convert = _get_statistic_to_display_unit_converter(unit, state_unit, units) - return {key: convert(value) if convert else value for key, value in result.items()} + if not convert: + return result + return {key: convert(value) for key, value in result.items()} _type_column_mapping = { From cc107bd0b7f5a11c3f2ff536e47f908f46d8bb67 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 29 May 2023 19:59:51 -0500 Subject: [PATCH 672/822] Add a filter to the tasmota device registry listener (#93640) We can avoid creating a task when the event is not a remove which will be most cases --- .../components/tasmota/device_automation.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/tasmota/device_automation.py b/homeassistant/components/tasmota/device_automation.py index 9b190855ad2d..98c7d1355c32 100644 --- a/homeassistant/components/tasmota/device_automation.py +++ b/homeassistant/components/tasmota/device_automation.py @@ -5,7 +5,7 @@ from hatasmota.models import DiscoveryHashType from hatasmota.trigger import TasmotaTrigger from homeassistant.config_entries import ConfigEntry -from homeassistant.core import Event, HomeAssistant +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers.device_registry import EVENT_DEVICE_REGISTRY_UPDATED from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -24,10 +24,13 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> N async def async_device_removed(event: Event) -> None: """Handle the removal of a device.""" - if event.data["action"] != "remove": - return await async_remove_automations(hass, event.data["device_id"]) + @callback + def _async_device_removed_filter(event: Event) -> bool: + """Filter device registry events.""" + return event.data["action"] == "remove" + async def async_discover( tasmota_automation: TasmotaTrigger, discovery_hash: DiscoveryHashType ) -> None: @@ -45,5 +48,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> N async_discover, ) hass.data[DATA_UNSUB].append( - hass.bus.async_listen(EVENT_DEVICE_REGISTRY_UPDATED, async_device_removed) + hass.bus.async_listen( + EVENT_DEVICE_REGISTRY_UPDATED, + async_device_removed, + event_filter=_async_device_removed_filter, + ) ) From 4cbbfaf55a53ea7358403d3adb3f895798afb0aa Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 30 May 2023 03:48:26 +0200 Subject: [PATCH 673/822] Update pydantic to 1.10.8 (#93772) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 6a3c3b7e0be8..077a74c1b3ac 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -13,7 +13,7 @@ freezegun==1.2.2 mock-open==1.4.0 mypy==1.3.0 pre-commit==3.1.0 -pydantic==1.10.7 +pydantic==1.10.8 pylint==2.17.4 pylint-per-file-ignores==1.1.0 pipdeptree==2.7.0 From 01a6173cc94443b55d5ebb0958fae469825205de Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 29 May 2023 19:45:22 -0700 Subject: [PATCH 674/822] Fix failing todoist test that used timezone before set by fixture (#93775) * Fix failing todoist test that used timezone before set by fixture * Merge fix for dt -> dt_util --- tests/components/todoist/test_calendar.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/components/todoist/test_calendar.py b/tests/components/todoist/test_calendar.py index c9f81f894044..921439fab455 100644 --- a/tests/components/todoist/test_calendar.py +++ b/tests/components/todoist/test_calendar.py @@ -4,6 +4,7 @@ from http import HTTPStatus from typing import Any from unittest.mock import AsyncMock, patch import urllib +import zoneinfo import pytest from todoist_api_python.models import Collaborator, Due, Label, Project, Task @@ -26,14 +27,16 @@ from homeassistant.util import dt as dt_util 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" +TIMEZONE = zoneinfo.ZoneInfo(TZ_NAME) @pytest.fixture(autouse=True) def set_time_zone(hass: HomeAssistant): """Set the time zone for the tests.""" - # Set our timezone to CST/Regina so we can check calculations - # This keeps UTC-6 all year round - hass.config.set_time_zone("America/Regina") + hass.config.set_time_zone(TZ_NAME) @pytest.fixture(name="due") @@ -189,7 +192,8 @@ async def test_update_entity_for_custom_project_no_due_date_on( "due", [ Due( - date=(dt_util.now() + timedelta(days=3)).strftime("%Y-%m-%d"), + # Note: This runs before the test fixture that sets the timezone + date=(dt_util.now(TIMEZONE) + timedelta(days=3)).strftime("%Y-%m-%d"), is_recurring=False, string="3 days from today", ) From c129b4d0c2a0a1008fd308e2304570b871ade976 Mon Sep 17 00:00:00 2001 From: Luke Date: Mon, 29 May 2023 22:45:53 -0400 Subject: [PATCH 675/822] Remove unneeded icon for Roborock sensor (#93774) removed unneeded icon --- homeassistant/components/roborock/sensor.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index 7fb608648113..da98b62c6f6b 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -81,7 +81,6 @@ CLEAN_INFORMATION_SENSORS = [ native_unit_of_measurement=UnitOfTime.SECONDS, key="cleaning_time", translation_key="cleaning_time", - icon="mdi:progress-clock", device_class=SensorDeviceClass.DURATION, value_fn=lambda data: data.status.clean_time, ), From c25ccb90a4cda1bbf0c227e27d456c34f55d19ed Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 29 May 2023 22:51:35 -0500 Subject: [PATCH 676/822] Speed up creating automations (#93776) Creating the inner coroutine for each automation to be run at started was expensive when the user had 1000s of automations --- .../components/automation/__init__.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 4712592edc7f..277952c5b2ec 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -578,6 +578,14 @@ class AutomationEntity(ToggleEntity, RestoreEntity): await super().async_will_remove_from_hass() await self.async_disable() + async def _async_enable_automation(self, event: Event) -> None: + """Start automation on startup.""" + # Don't do anything if no longer enabled or already attached + if not self._is_enabled or self._async_detach_triggers is not None: + return + + self._async_detach_triggers = await self._async_attach_triggers(True) + async def async_enable(self) -> None: """Enable this automation entity. @@ -594,16 +602,8 @@ class AutomationEntity(ToggleEntity, RestoreEntity): self.async_write_ha_state() return - async def async_enable_automation(event: Event) -> None: - """Start automation on startup.""" - # Don't do anything if no longer enabled or already attached - if not self._is_enabled or self._async_detach_triggers is not None: - return - - self._async_detach_triggers = await self._async_attach_triggers(True) - self.hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STARTED, async_enable_automation + EVENT_HOMEASSISTANT_STARTED, self._async_enable_automation ) self.async_write_ha_state() From 49ae298c55fde99c5b3fa543e0e972e08ebe5813 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 30 May 2023 08:35:47 +0200 Subject: [PATCH 677/822] Use entity attributes in Obihai sensor (#93564) --- homeassistant/components/obihai/sensor.py | 61 +++++++---------------- 1 file changed, 19 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/obihai/sensor.py b/homeassistant/components/obihai/sensor.py index b838d0ec92b5..010b97800766 100644 --- a/homeassistant/components/obihai/sensor.py +++ b/homeassistant/components/obihai/sensor.py @@ -50,65 +50,37 @@ class ObihaiServiceSensors(SensorEntity): def __init__(self, pyobihai: PyObihai, serial: str, service_name: str) -> None: """Initialize monitor sensor.""" self._service_name = service_name - self._state = None - self._name = f"{OBIHAI} {self._service_name}" + self._attr_name = f"{OBIHAI} {self._service_name}" self._pyobihai = pyobihai - self._unique_id = f"{serial}-{self._service_name}" - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - - @property - def available(self): - """Return if sensor is available.""" - if self._state is not None: - return True - return False - - @property - def unique_id(self): - """Return the unique ID.""" - return self._unique_id - - @property - def device_class(self): - """Return the device class for uptime sensor.""" + self._attr_unique_id = f"{serial}-{self._service_name}" if self._service_name == "Last Reboot": - return SensorDeviceClass.TIMESTAMP - return None + self._attr_device_class = SensorDeviceClass.TIMESTAMP @property - def icon(self): + def icon(self) -> str: """Return an icon.""" if self._service_name == "Call Direction": - if self._state == "No Active Calls": + if self._attr_native_value == "No Active Calls": return "mdi:phone-off" - if self._state == "Inbound Call": + if self._attr_native_value == "Inbound Call": return "mdi:phone-incoming" return "mdi:phone-outgoing" if "Caller Info" in self._service_name: return "mdi:phone-log" if "Port" in self._service_name: - if self._state == "Ringing": + if self._attr_native_value == "Ringing": return "mdi:phone-ring" - if self._state == "Off Hook": + if self._attr_native_value == "Off Hook": return "mdi:phone-in-talk" return "mdi:phone-hangup" if "Service Status" in self._service_name: if "OBiTALK Service Status" in self._service_name: return "mdi:phone-check" - if self._state == "0": + if self._attr_native_value == "0": return "mdi:phone-hangup" return "mdi:phone-in-talk" if "Reboot Required" in self._service_name: - if self._state == "false": + if self._attr_native_value == "false": return "mdi:restart-off" return "mdi:restart-alert" return "mdi:phone" @@ -116,20 +88,25 @@ class ObihaiServiceSensors(SensorEntity): def update(self) -> None: """Update the sensor.""" if not self._pyobihai.check_account(): - self._state = None + self._attr_native_value = None + self._attr_available = False return services = self._pyobihai.get_state() if self._service_name in services: - self._state = services.get(self._service_name) + self._attr_native_value = services.get(self._service_name) services = self._pyobihai.get_line_state() if services is not None and self._service_name in services: - self._state = services.get(self._service_name) + self._attr_native_value = services.get(self._service_name) call_direction = self._pyobihai.get_call_direction() if self._service_name in call_direction: - self._state = call_direction.get(self._service_name) + self._attr_native_value = call_direction.get(self._service_name) + + if self._attr_native_value is None: + self._attr_available = False + self._attr_available = True From 901624ad6fb8f765e8e9aca2e06fa8aba1d6e653 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 30 May 2023 10:11:21 +0200 Subject: [PATCH 678/822] Adapt otbr to upstream openthread REST API changes (#93544) * Adapt otbr to upstream openthread REST API changes * Bump python-otbr-api to 2.0.0 --- homeassistant/components/otbr/config_flow.py | 2 +- homeassistant/components/otbr/manifest.json | 2 +- homeassistant/components/otbr/util.py | 2 +- .../components/otbr/websocket_api.py | 2 +- homeassistant/components/thread/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/otbr/test_config_flow.py | 30 +++++++++---------- tests/components/otbr/test_websocket_api.py | 4 +-- 9 files changed, 23 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/otbr/config_flow.py b/homeassistant/components/otbr/config_flow.py index 32842ad6cc78..0a554806c326 100644 --- a/homeassistant/components/otbr/config_flow.py +++ b/homeassistant/components/otbr/config_flow.py @@ -50,7 +50,7 @@ class OTBRConfigFlow(ConfigFlow, domain=DOMAIN): "not importing TLV with channel %s", thread_dataset_channel ) await api.create_active_dataset( - python_otbr_api.OperationalDataSet( + python_otbr_api.ActiveDataSet( channel=allowed_channel if allowed_channel else DEFAULT_CHANNEL, network_name="home-assistant", ) diff --git a/homeassistant/components/otbr/manifest.json b/homeassistant/components/otbr/manifest.json index c10a2417dc6a..f092bff48b9a 100644 --- a/homeassistant/components/otbr/manifest.json +++ b/homeassistant/components/otbr/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/otbr", "integration_type": "service", "iot_class": "local_polling", - "requirements": ["python-otbr-api==1.0.9"] + "requirements": ["python-otbr-api==2.0.0"] } diff --git a/homeassistant/components/otbr/util.py b/homeassistant/components/otbr/util.py index b2ce05f280ca..a625ba09018a 100644 --- a/homeassistant/components/otbr/util.py +++ b/homeassistant/components/otbr/util.py @@ -79,7 +79,7 @@ class OTBRData: @_handle_otbr_error async def create_active_dataset( - self, dataset: python_otbr_api.OperationalDataSet + self, dataset: python_otbr_api.ActiveDataSet ) -> None: """Create an active operational dataset.""" return await self.api.create_active_dataset(dataset) diff --git a/homeassistant/components/otbr/websocket_api.py b/homeassistant/components/otbr/websocket_api.py index 2189df363ba0..7fd0f8187b27 100644 --- a/homeassistant/components/otbr/websocket_api.py +++ b/homeassistant/components/otbr/websocket_api.py @@ -80,7 +80,7 @@ async def websocket_create_network( try: await data.create_active_dataset( - python_otbr_api.OperationalDataSet( + python_otbr_api.ActiveDataSet( channel=channel, network_name="home-assistant" ) ) diff --git a/homeassistant/components/thread/manifest.json b/homeassistant/components/thread/manifest.json index 3d61315f3d17..da142be49aa3 100644 --- a/homeassistant/components/thread/manifest.json +++ b/homeassistant/components/thread/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/thread", "integration_type": "service", "iot_class": "local_polling", - "requirements": ["python-otbr-api==1.0.9", "pyroute2==0.7.5"], + "requirements": ["python-otbr-api==2.0.0", "pyroute2==0.7.5"], "zeroconf": ["_meshcop._udp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index fa680bf6df9d..a0493a79d353 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2109,7 +2109,7 @@ python-opensky==0.0.7 # homeassistant.components.otbr # homeassistant.components.thread -python-otbr-api==1.0.9 +python-otbr-api==2.0.0 # homeassistant.components.picnic python-picnic-api==1.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4e3847687c6a..f64d37ff45f4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1532,7 +1532,7 @@ python-nest==4.2.0 # homeassistant.components.otbr # homeassistant.components.thread -python-otbr-api==1.0.9 +python-otbr-api==2.0.0 # homeassistant.components.picnic python-picnic-api==1.1.0 diff --git a/tests/components/otbr/test_config_flow.py b/tests/components/otbr/test_config_flow.py index 9fe30d709a73..faec90282df0 100644 --- a/tests/components/otbr/test_config_flow.py +++ b/tests/components/otbr/test_config_flow.py @@ -72,8 +72,8 @@ async def test_user_flow_router_not_setup( """ url = "http://custom_url:1234" aioclient_mock.get(f"{url}/node/dataset/active", status=HTTPStatus.NO_CONTENT) - aioclient_mock.post(f"{url}/node/dataset/active", status=HTTPStatus.ACCEPTED) - aioclient_mock.post(f"{url}/node/state", status=HTTPStatus.OK) + aioclient_mock.put(f"{url}/node/dataset/active", status=HTTPStatus.CREATED) + aioclient_mock.put(f"{url}/node/state", status=HTTPStatus.OK) result = await hass.config_entries.flow.async_init( otbr.DOMAIN, context={"source": "user"} @@ -96,14 +96,14 @@ async def test_user_flow_router_not_setup( ) # Check we create a dataset and enable the router - assert aioclient_mock.mock_calls[-2][0] == "POST" + assert aioclient_mock.mock_calls[-2][0] == "PUT" assert aioclient_mock.mock_calls[-2][1].path == "/node/dataset/active" assert aioclient_mock.mock_calls[-2][2] == { "Channel": 15, "NetworkName": "home-assistant", } - assert aioclient_mock.mock_calls[-1][0] == "POST" + assert aioclient_mock.mock_calls[-1][0] == "PUT" assert aioclient_mock.mock_calls[-1][1].path == "/node/state" assert aioclient_mock.mock_calls[-1][2] == "enable" @@ -216,8 +216,8 @@ async def test_hassio_discovery_flow_router_not_setup( """ url = "http://core-silabs-multiprotocol:8081" aioclient_mock.get(f"{url}/node/dataset/active", status=HTTPStatus.NO_CONTENT) - aioclient_mock.post(f"{url}/node/dataset/active", status=HTTPStatus.ACCEPTED) - aioclient_mock.post(f"{url}/node/state", status=HTTPStatus.OK) + aioclient_mock.put(f"{url}/node/dataset/active", status=HTTPStatus.CREATED) + aioclient_mock.put(f"{url}/node/state", status=HTTPStatus.OK) with patch( "homeassistant.components.otbr.config_flow.async_get_preferred_dataset", @@ -231,14 +231,14 @@ async def test_hassio_discovery_flow_router_not_setup( ) # Check we create a dataset and enable the router - assert aioclient_mock.mock_calls[-2][0] == "POST" + assert aioclient_mock.mock_calls[-2][0] == "PUT" assert aioclient_mock.mock_calls[-2][1].path == "/node/dataset/active" assert aioclient_mock.mock_calls[-2][2] == { "Channel": 15, "NetworkName": "home-assistant", } - assert aioclient_mock.mock_calls[-1][0] == "POST" + assert aioclient_mock.mock_calls[-1][0] == "PUT" assert aioclient_mock.mock_calls[-1][1].path == "/node/state" assert aioclient_mock.mock_calls[-1][2] == "enable" @@ -268,8 +268,8 @@ async def test_hassio_discovery_flow_router_not_setup_has_preferred( """ url = "http://core-silabs-multiprotocol:8081" aioclient_mock.get(f"{url}/node/dataset/active", status=HTTPStatus.NO_CONTENT) - aioclient_mock.put(f"{url}/node/dataset/active", status=HTTPStatus.ACCEPTED) - aioclient_mock.post(f"{url}/node/state", status=HTTPStatus.OK) + aioclient_mock.put(f"{url}/node/dataset/active", status=HTTPStatus.CREATED) + aioclient_mock.put(f"{url}/node/state", status=HTTPStatus.OK) with patch( "homeassistant.components.otbr.config_flow.async_get_preferred_dataset", @@ -287,7 +287,7 @@ async def test_hassio_discovery_flow_router_not_setup_has_preferred( assert aioclient_mock.mock_calls[-2][1].path == "/node/dataset/active" assert aioclient_mock.mock_calls[-2][2] == DATASET_CH15.hex() - assert aioclient_mock.mock_calls[-1][0] == "POST" + assert aioclient_mock.mock_calls[-1][0] == "PUT" assert aioclient_mock.mock_calls[-1][1].path == "/node/state" assert aioclient_mock.mock_calls[-1][2] == "enable" @@ -318,8 +318,8 @@ async def test_hassio_discovery_flow_router_not_setup_has_preferred_2( """ url = "http://core-silabs-multiprotocol:8081" aioclient_mock.get(f"{url}/node/dataset/active", status=HTTPStatus.NO_CONTENT) - aioclient_mock.post(f"{url}/node/dataset/active", status=HTTPStatus.ACCEPTED) - aioclient_mock.post(f"{url}/node/state", status=HTTPStatus.OK) + aioclient_mock.put(f"{url}/node/dataset/active", status=HTTPStatus.CREATED) + aioclient_mock.put(f"{url}/node/state", status=HTTPStatus.OK) networksettings = Mock() networksettings.network_info.channel = 15 @@ -342,14 +342,14 @@ async def test_hassio_discovery_flow_router_not_setup_has_preferred_2( ) # Check we create a dataset and enable the router - assert aioclient_mock.mock_calls[-2][0] == "POST" + assert aioclient_mock.mock_calls[-2][0] == "PUT" assert aioclient_mock.mock_calls[-2][1].path == "/node/dataset/active" assert aioclient_mock.mock_calls[-2][2] == { "Channel": 15, "NetworkName": "home-assistant", } - assert aioclient_mock.mock_calls[-1][0] == "POST" + assert aioclient_mock.mock_calls[-1][0] == "PUT" assert aioclient_mock.mock_calls[-1][1].path == "/node/state" assert aioclient_mock.mock_calls[-1][2] == "enable" diff --git a/tests/components/otbr/test_websocket_api.py b/tests/components/otbr/test_websocket_api.py index e6f492f5e5ff..bfc3f09d6feb 100644 --- a/tests/components/otbr/test_websocket_api.py +++ b/tests/components/otbr/test_websocket_api.py @@ -97,9 +97,7 @@ async def test_create_network( assert msg["result"] is None create_dataset_mock.assert_called_once_with( - python_otbr_api.models.OperationalDataSet( - channel=15, network_name="home-assistant" - ) + python_otbr_api.models.ActiveDataSet(channel=15, network_name="home-assistant") ) assert len(set_enabled_mock.mock_calls) == 2 assert set_enabled_mock.mock_calls[0][1][0] is False From 2df49b416c272858b3d888f4391622ceaa8393c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Tue, 30 May 2023 10:23:14 +0200 Subject: [PATCH 679/822] Add WebServer sensors to Airzone Cloud (#93757) --- .../components/airzone_cloud/entity.py | 42 ++++++++++-- .../components/airzone_cloud/sensor.py | 67 +++++++++++++++++-- tests/components/airzone_cloud/test_sensor.py | 7 ++ 3 files changed, 108 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/airzone_cloud/entity.py b/homeassistant/components/airzone_cloud/entity.py index e2a3142f1f5a..59f6aa14bf8c 100644 --- a/homeassistant/components/airzone_cloud/entity.py +++ b/homeassistant/components/airzone_cloud/entity.py @@ -6,13 +6,16 @@ from typing import Any from aioairzone_cloud.const import ( AZD_AIDOOS, + AZD_FIRMWARE, AZD_NAME, AZD_SYSTEM_ID, AZD_WEBSERVER, + AZD_WEBSERVERS, AZD_ZONES, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -44,10 +47,10 @@ class AirzoneAidooEntity(AirzoneEntity): self.aidoo_id = aidoo_id self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, f"{entry.unique_id}_{aidoo_id}")}, + identifiers={(DOMAIN, aidoo_id)}, manufacturer=MANUFACTURER, name=aidoo_data[AZD_NAME], - via_device=(DOMAIN, f"{entry.unique_id}_{aidoo_data[AZD_WEBSERVER]}"), + via_device=(DOMAIN, aidoo_data[AZD_WEBSERVER]), ) def get_airzone_value(self, key: str) -> Any: @@ -58,6 +61,37 @@ class AirzoneAidooEntity(AirzoneEntity): return value +class AirzoneWebServerEntity(AirzoneEntity): + """Define an Airzone Cloud WebServer entity.""" + + def __init__( + self, + coordinator: AirzoneUpdateCoordinator, + entry: ConfigEntry, + ws_id: str, + ws_data: dict[str, Any], + ) -> None: + """Initialize.""" + super().__init__(coordinator) + + self.ws_id = ws_id + + self._attr_device_info = DeviceInfo( + connections={(dr.CONNECTION_NETWORK_MAC, ws_id)}, + identifiers={(DOMAIN, ws_id)}, + manufacturer=MANUFACTURER, + name=f"WebServer {ws_id}", + sw_version=ws_data[AZD_FIRMWARE], + ) + + def get_airzone_value(self, key: str) -> Any: + """Return WebServer value by key.""" + value = None + if webserver := self.coordinator.data[AZD_WEBSERVERS].get(self.ws_id): + value = webserver.get(key) + return value + + class AirzoneZoneEntity(AirzoneEntity): """Define an Airzone Cloud Zone entity.""" @@ -75,10 +109,10 @@ class AirzoneZoneEntity(AirzoneEntity): self.zone_id = zone_id self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, f"{entry.unique_id}_{zone_id}")}, + identifiers={(DOMAIN, zone_id)}, manufacturer=MANUFACTURER, name=zone_data[AZD_NAME], - via_device=(DOMAIN, f"{entry.unique_id}_{self.system_id}"), + via_device=(DOMAIN, self.system_id), ) def get_airzone_value(self, key: str) -> Any: diff --git a/homeassistant/components/airzone_cloud/sensor.py b/homeassistant/components/airzone_cloud/sensor.py index faeb62576fd5..ee162ef5fecd 100644 --- a/homeassistant/components/airzone_cloud/sensor.py +++ b/homeassistant/components/airzone_cloud/sensor.py @@ -8,6 +8,8 @@ from aioairzone_cloud.const import ( AZD_HUMIDITY, AZD_NAME, AZD_TEMP, + AZD_WEBSERVERS, + AZD_WIFI_RSSI, AZD_ZONES, ) @@ -18,13 +20,23 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import PERCENTAGE, UnitOfTemperature +from homeassistant.const import ( + PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + EntityCategory, + UnitOfTemperature, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN from .coordinator import AirzoneUpdateCoordinator -from .entity import AirzoneAidooEntity, AirzoneEntity, AirzoneZoneEntity +from .entity import ( + AirzoneAidooEntity, + AirzoneEntity, + AirzoneWebServerEntity, + AirzoneZoneEntity, +) AIDOO_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = ( SensorEntityDescription( @@ -36,6 +48,19 @@ AIDOO_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = ( ), ) +WEBSERVER_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = ( + SensorEntityDescription( + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + has_entity_name=True, + key=AZD_WIFI_RSSI, + name="RSSI", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + state_class=SensorStateClass.MEASUREMENT, + ), +) + ZONE_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = ( SensorEntityDescription( device_class=SensorDeviceClass.TEMPERATURE, @@ -76,6 +101,20 @@ async def async_setup_entry( ) ) + # WebServers + for ws_id, ws_data in coordinator.data.get(AZD_WEBSERVERS, {}).items(): + for description in WEBSERVER_SENSOR_TYPES: + if description.key in ws_data: + sensors.append( + AirzoneWebServerSensor( + coordinator, + description, + entry, + ws_id, + ws_data, + ) + ) + # Zones for zone_id, zone_data in coordinator.data.get(AZD_ZONES, {}).items(): for description in ZONE_SENSOR_TYPES: @@ -123,7 +162,27 @@ class AirzoneAidooSensor(AirzoneAidooEntity, AirzoneSensor): super().__init__(coordinator, entry, aidoo_id, aidoo_data) self._attr_name = f"{aidoo_data[AZD_NAME]} {description.name}" - self._attr_unique_id = f"{entry.unique_id}_{aidoo_id}_{description.key}" + self._attr_unique_id = f"{aidoo_id}_{description.key}" + self.entity_description = description + + self._async_update_attrs() + + +class AirzoneWebServerSensor(AirzoneWebServerEntity, AirzoneSensor): + """Define an Airzone Cloud WebServer sensor.""" + + def __init__( + self, + coordinator: AirzoneUpdateCoordinator, + description: SensorEntityDescription, + entry: ConfigEntry, + ws_id: str, + ws_data: dict[str, Any], + ) -> None: + """Initialize.""" + super().__init__(coordinator, entry, ws_id, ws_data) + + self._attr_unique_id = f"{ws_id}_{description.key}" self.entity_description = description self._async_update_attrs() @@ -144,7 +203,7 @@ class AirzoneZoneSensor(AirzoneZoneEntity, AirzoneSensor): super().__init__(coordinator, entry, zone_id, zone_data) self._attr_name = f"{zone_data[AZD_NAME]} {description.name}" - self._attr_unique_id = f"{entry.unique_id}_{zone_id}_{description.key}" + self._attr_unique_id = f"{zone_id}_{description.key}" self.entity_description = description self._async_update_attrs() diff --git a/tests/components/airzone_cloud/test_sensor.py b/tests/components/airzone_cloud/test_sensor.py index 9de134d718b5..41807fac78bc 100644 --- a/tests/components/airzone_cloud/test_sensor.py +++ b/tests/components/airzone_cloud/test_sensor.py @@ -16,6 +16,13 @@ async def test_airzone_create_sensors( state = hass.states.get("sensor.bron_temperature") assert state.state == "21.0" + # WebServers + state = hass.states.get("sensor.webserver_11_22_33_44_55_66_rssi") + assert state.state == "-56" + + state = hass.states.get("sensor.webserver_11_22_33_44_55_67_rssi") + assert state.state == "-77" + # Zones state = hass.states.get("sensor.dormitorio_temperature") assert state.state == "25.0" From c513934f257e7748c853950f6885a53c77c2b0fd Mon Sep 17 00:00:00 2001 From: Brandon Rothweiler Date: Tue, 30 May 2023 05:39:55 -0400 Subject: [PATCH 680/822] Fix broken URL in MQTT discovery log message (#93778) --- homeassistant/components/mqtt/discovery.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index 342e7d121f2e..0c0032ec8eb0 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -113,7 +113,7 @@ async def async_start( # noqa: C901 "Received message on illegal discovery topic '%s'. The topic" " contains " "not allowed characters. For more information see " - "https://www.home-assistant.io/docs/mqtt/discovery/#discovery-topic" + "https://www.home-assistant.io/integrations/mqtt/#discovery-topic" ), topic, ) From 16d8c8d4d5227d53ab98db38a2953d8cb5923ade Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 30 May 2023 12:47:46 +0200 Subject: [PATCH 681/822] Bump python-otbr-api to 2.1.0 (#93790) * Bump python-otbr-api to 2.1.0 * Fix tests --- homeassistant/components/otbr/config_flow.py | 6 ++-- homeassistant/components/otbr/manifest.json | 2 +- homeassistant/components/otbr/util.py | 29 +++++++++---------- .../components/otbr/websocket_api.py | 7 +++-- .../components/thread/dataset_store.py | 20 +++++++------ .../components/thread/diagnostics.py | 3 +- homeassistant/components/thread/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/otbr/test_init.py | 10 +------ tests/components/thread/test_dataset_store.py | 9 +----- 11 files changed, 41 insertions(+), 51 deletions(-) diff --git a/homeassistant/components/otbr/config_flow.py b/homeassistant/components/otbr/config_flow.py index 0a554806c326..c8ab8246c8be 100644 --- a/homeassistant/components/otbr/config_flow.py +++ b/homeassistant/components/otbr/config_flow.py @@ -3,10 +3,12 @@ from __future__ import annotations import asyncio import logging +from typing import cast import aiohttp import python_otbr_api from python_otbr_api import tlv_parser +from python_otbr_api.tlv_parser import MeshcopTLVType import voluptuous as vol import yarl @@ -38,8 +40,8 @@ class OTBRConfigFlow(ConfigFlow, domain=DOMAIN): thread_dataset_tlv = await async_get_preferred_dataset(self.hass) if thread_dataset_tlv: dataset = tlv_parser.parse_tlv(thread_dataset_tlv) - if channel_str := dataset.get(tlv_parser.MeshcopTLVType.CHANNEL): - thread_dataset_channel = int(channel_str, base=16) + if channel := dataset.get(MeshcopTLVType.CHANNEL): + thread_dataset_channel = cast(tlv_parser.Channel, channel).channel if thread_dataset_tlv is not None and ( not allowed_channel or allowed_channel == thread_dataset_channel diff --git a/homeassistant/components/otbr/manifest.json b/homeassistant/components/otbr/manifest.json index f092bff48b9a..f04e15a549c6 100644 --- a/homeassistant/components/otbr/manifest.json +++ b/homeassistant/components/otbr/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/otbr", "integration_type": "service", "iot_class": "local_polling", - "requirements": ["python-otbr-api==2.0.0"] + "requirements": ["python-otbr-api==2.1.0"] } diff --git a/homeassistant/components/otbr/util.py b/homeassistant/components/otbr/util.py index a625ba09018a..5541ecb68748 100644 --- a/homeassistant/components/otbr/util.py +++ b/homeassistant/components/otbr/util.py @@ -5,11 +5,12 @@ from collections.abc import Callable, Coroutine import contextlib import dataclasses from functools import wraps -from typing import Any, Concatenate, ParamSpec, TypeVar +from typing import Any, Concatenate, ParamSpec, TypeVar, cast import python_otbr_api from python_otbr_api import tlv_parser from python_otbr_api.pskc import compute_pskc +from python_otbr_api.tlv_parser import MeshcopTLVType from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( is_multiprotocol_url, @@ -146,14 +147,10 @@ async def _warn_on_channel_collision( dataset = tlv_parser.parse_tlv(dataset_tlvs.hex()) - if (channel_s := dataset.get(tlv_parser.MeshcopTLVType.CHANNEL)) is None: - delete_issue() - return - try: - channel = int(channel_s, 16) - except ValueError: + if (channel_s := dataset.get(MeshcopTLVType.CHANNEL)) is None: delete_issue() return + channel = cast(tlv_parser.Channel, channel_s).channel if channel == allowed_channel: delete_issue() @@ -186,20 +183,20 @@ def _warn_on_default_network_settings( insecure = False if ( - network_key := dataset.get(tlv_parser.MeshcopTLVType.NETWORKKEY) - ) is not None and bytes.fromhex(network_key) in INSECURE_NETWORK_KEYS: + network_key := dataset.get(MeshcopTLVType.NETWORKKEY) + ) is not None and network_key.data in INSECURE_NETWORK_KEYS: insecure = True if ( not insecure - and tlv_parser.MeshcopTLVType.EXTPANID in dataset - and tlv_parser.MeshcopTLVType.NETWORKNAME in dataset - and tlv_parser.MeshcopTLVType.PSKC in dataset + and MeshcopTLVType.EXTPANID in dataset + and MeshcopTLVType.NETWORKNAME in dataset + and MeshcopTLVType.PSKC in dataset ): - ext_pan_id = dataset[tlv_parser.MeshcopTLVType.EXTPANID] - network_name = dataset[tlv_parser.MeshcopTLVType.NETWORKNAME] - pskc = bytes.fromhex(dataset[tlv_parser.MeshcopTLVType.PSKC]) + ext_pan_id = dataset[MeshcopTLVType.EXTPANID] + network_name = cast(tlv_parser.NetworkName, dataset[MeshcopTLVType.NETWORKNAME]) + pskc = dataset[MeshcopTLVType.PSKC].data for passphrase in INSECURE_PASSPHRASES: - if pskc == compute_pskc(ext_pan_id, network_name, passphrase): + if pskc == compute_pskc(ext_pan_id.data, network_name.name, passphrase): insecure = True break diff --git a/homeassistant/components/otbr/websocket_api.py b/homeassistant/components/otbr/websocket_api.py index 7fd0f8187b27..0dcce288348b 100644 --- a/homeassistant/components/otbr/websocket_api.py +++ b/homeassistant/components/otbr/websocket_api.py @@ -1,7 +1,10 @@ """Websocket API for OTBR.""" +from typing import cast + import python_otbr_api from python_otbr_api import tlv_parser +from python_otbr_api.tlv_parser import MeshcopTLVType import voluptuous as vol from homeassistant.components import websocket_api @@ -133,8 +136,8 @@ async def websocket_set_network( connection.send_error(msg["id"], "unknown_dataset", "Unknown dataset") return dataset = tlv_parser.parse_tlv(dataset_tlv) - if channel_str := dataset.get(tlv_parser.MeshcopTLVType.CHANNEL): - thread_dataset_channel = int(channel_str, base=16) + if channel := dataset.get(MeshcopTLVType.CHANNEL): + thread_dataset_channel = cast(tlv_parser.Channel, channel).channel data: OTBRData = hass.data[DOMAIN] allowed_channel = await get_allowed_channel(hass, data.url) diff --git a/homeassistant/components/thread/dataset_store.py b/homeassistant/components/thread/dataset_store.py index bcadf3ca5fbe..a277ceb16a24 100644 --- a/homeassistant/components/thread/dataset_store.py +++ b/homeassistant/components/thread/dataset_store.py @@ -1,13 +1,13 @@ """Persistently store thread datasets.""" from __future__ import annotations -from contextlib import suppress import dataclasses from datetime import datetime from functools import cached_property from typing import Any, cast from python_otbr_api import tlv_parser +from python_otbr_api.tlv_parser import MeshcopTLVType from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -39,31 +39,33 @@ class DatasetEntry: @property def channel(self) -> int | None: """Return channel as an integer.""" - if (channel := self.dataset.get(tlv_parser.MeshcopTLVType.CHANNEL)) is None: + if (channel := self.dataset.get(MeshcopTLVType.CHANNEL)) is None: return None - with suppress(ValueError): - return int(channel, 16) - return None + return cast(tlv_parser.Channel, channel).channel @cached_property - def dataset(self) -> dict[tlv_parser.MeshcopTLVType, str]: + def dataset(self) -> dict[MeshcopTLVType, tlv_parser.MeshcopTLVItem]: """Return the dataset in dict format.""" return tlv_parser.parse_tlv(self.tlv) @property def extended_pan_id(self) -> str | None: """Return extended PAN ID as a hex string.""" - return self.dataset.get(tlv_parser.MeshcopTLVType.EXTPANID) + if (ext_pan_id := self.dataset.get(MeshcopTLVType.EXTPANID)) is None: + return None + return str(ext_pan_id) @property def network_name(self) -> str | None: """Return network name as a string.""" - return self.dataset.get(tlv_parser.MeshcopTLVType.NETWORKNAME) + if (name := self.dataset.get(MeshcopTLVType.NETWORKNAME)) is None: + return None + return cast(tlv_parser.NetworkName, name).name @property def pan_id(self) -> str | None: """Return PAN ID as a hex string.""" - return self.dataset.get(tlv_parser.MeshcopTLVType.PANID) + return str(self.dataset.get(MeshcopTLVType.PANID)) def to_json(self) -> dict[str, Any]: """Return a JSON serializable representation for storage.""" diff --git a/homeassistant/components/thread/diagnostics.py b/homeassistant/components/thread/diagnostics.py index 8dc5dd43041e..e6149214af4d 100644 --- a/homeassistant/components/thread/diagnostics.py +++ b/homeassistant/components/thread/diagnostics.py @@ -148,7 +148,8 @@ async def async_get_config_entry_diagnostics( "unexpected_routers": set(), }, ) - if mlp := record.dataset.get(MeshcopTLVType.MESHLOCALPREFIX): + if mlp_item := record.dataset.get(MeshcopTLVType.MESHLOCALPREFIX): + mlp = str(mlp_item) network["prefixes"].add(f"{mlp[0:4]}:{mlp[4:8]}:{mlp[8:12]}:{mlp[12:16]}") # Find all routes currently act that might be thread related, so we can match them to diff --git a/homeassistant/components/thread/manifest.json b/homeassistant/components/thread/manifest.json index da142be49aa3..9a6a64481cd5 100644 --- a/homeassistant/components/thread/manifest.json +++ b/homeassistant/components/thread/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/thread", "integration_type": "service", "iot_class": "local_polling", - "requirements": ["python-otbr-api==2.0.0", "pyroute2==0.7.5"], + "requirements": ["python-otbr-api==2.1.0", "pyroute2==0.7.5"], "zeroconf": ["_meshcop._udp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index a0493a79d353..e224ec3294b6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2109,7 +2109,7 @@ python-opensky==0.0.7 # homeassistant.components.otbr # homeassistant.components.thread -python-otbr-api==2.0.0 +python-otbr-api==2.1.0 # homeassistant.components.picnic python-picnic-api==1.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f64d37ff45f4..c526b1b88595 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1532,7 +1532,7 @@ python-nest==4.2.0 # homeassistant.components.otbr # homeassistant.components.thread -python-otbr-api==2.0.0 +python-otbr-api==2.1.0 # homeassistant.components.picnic python-picnic-api==1.1.0 diff --git a/tests/components/otbr/test_init.py b/tests/components/otbr/test_init.py index bf481488a0a9..3d646287ce17 100644 --- a/tests/components/otbr/test_init.py +++ b/tests/components/otbr/test_init.py @@ -24,12 +24,6 @@ from . import ( from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker -DATASET_BAD_CHANNEL = bytes.fromhex( - "0E080000000000010000000035060004001FFFE00208F642646DA209B1C00708FDF57B5A" - "0FE2AAF60510DE98B5BA1A528FEE049D4B4B01835375030D4F70656E5468726561642048410102" - "25A40410F5DD18371BFD29E1A601EF6FFAD94C030C0402A0F7F8" -) - DATASET_NO_CHANNEL = bytes.fromhex( "0E08000000000001000035060004001FFFE00208F642646DA209B1C00708FDF57B5A" "0FE2AAF60510DE98B5BA1A528FEE049D4B4B01835375030D4F70656E5468726561642048410102" @@ -103,9 +97,7 @@ async def test_import_share_radio_channel_collision(hass: HomeAssistant) -> None ) -@pytest.mark.parametrize( - "dataset", [DATASET_BAD_CHANNEL, DATASET_CH15, DATASET_NO_CHANNEL] -) +@pytest.mark.parametrize("dataset", [DATASET_CH15, DATASET_NO_CHANNEL]) async def test_import_share_radio_no_channel_collision( hass: HomeAssistant, dataset: bytes ) -> None: diff --git a/tests/components/thread/test_dataset_store.py b/tests/components/thread/test_dataset_store.py index 212db0de06f1..45efd232ba71 100644 --- a/tests/components/thread/test_dataset_store.py +++ b/tests/components/thread/test_dataset_store.py @@ -121,7 +121,6 @@ async def test_dataset_properties(hass: HomeAssistant) -> None: {"source": "Google", "tlv": DATASET_1}, {"source": "Multipan", "tlv": DATASET_2}, {"source": "🎅", "tlv": DATASET_3}, - {"source": "test1", "tlv": DATASET_1_BAD_CHANNEL}, {"source": "test2", "tlv": DATASET_1_NO_CHANNEL}, ] @@ -136,10 +135,8 @@ async def test_dataset_properties(hass: HomeAssistant) -> None: dataset_2 = dataset if dataset.source == "🎅": dataset_3 = dataset - if dataset.source == "test1": - dataset_4 = dataset if dataset.source == "test2": - dataset_5 = dataset + dataset_4 = dataset dataset = store.async_get(dataset_1.id) assert dataset == dataset_1 @@ -166,10 +163,6 @@ async def test_dataset_properties(hass: HomeAssistant) -> None: assert dataset == dataset_4 assert dataset.channel is None - dataset = store.async_get(dataset_5.id) - assert dataset == dataset_5 - assert dataset.channel is None - async def test_load_datasets(hass: HomeAssistant) -> None: """Make sure that we can load/save data correctly.""" From 1a9da67a2875ebafb30a0dcff36d2c24d5afd7a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Diego=20Rodr=C3=ADguez=20Royo?= Date: Tue, 30 May 2023 13:05:14 +0200 Subject: [PATCH 682/822] Add tilt support to Matter cover (#92256) --- homeassistant/components/matter/cover.py | 95 ++++- .../fixtures/nodes/window-covering_full.json | 268 +++++++++++++ .../fixtures/nodes/window-covering_lift.json | 249 ++++++++++++ ...ring.json => window-covering_pa-lift.json} | 0 .../nodes/window-covering_pa-tilt.json | 254 ++++++++++++ .../fixtures/nodes/window-covering_tilt.json | 249 ++++++++++++ tests/components/matter/test_cover.py | 366 ++++++++++++++++-- 7 files changed, 1438 insertions(+), 43 deletions(-) create mode 100644 tests/components/matter/fixtures/nodes/window-covering_full.json create mode 100644 tests/components/matter/fixtures/nodes/window-covering_lift.json rename tests/components/matter/fixtures/nodes/{window-covering.json => window-covering_pa-lift.json} (100%) create mode 100644 tests/components/matter/fixtures/nodes/window-covering_pa-tilt.json create mode 100644 tests/components/matter/fixtures/nodes/window-covering_tilt.json diff --git a/homeassistant/components/matter/cover.py b/homeassistant/components/matter/cover.py index 4e227d83b44e..9e72b69f3353 100644 --- a/homeassistant/components/matter/cover.py +++ b/homeassistant/components/matter/cover.py @@ -8,6 +8,7 @@ from chip.clusters import Objects as clusters from homeassistant.components.cover import ( ATTR_POSITION, + ATTR_TILT_POSITION, CoverDeviceClass, CoverEntity, CoverEntityDescription, @@ -56,12 +57,6 @@ class MatterCover(MatterEntity, CoverEntity): """Representation of a Matter Cover.""" entity_description: CoverEntityDescription - _attr_supported_features = ( - CoverEntityFeature.OPEN - | CoverEntityFeature.CLOSE - | CoverEntityFeature.STOP - | CoverEntityFeature.SET_POSITION - ) @property def is_closed(self) -> bool: @@ -88,6 +83,14 @@ class MatterCover(MatterEntity, CoverEntity): clusters.WindowCovering.Commands.GoToLiftPercentage((100 - position) * 100) ) + async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: + """Set the cover tilt to a specific position.""" + position = kwargs[ATTR_TILT_POSITION] + await self.send_device_command( + # value needs to be inverted and is sent in 100ths + clusters.WindowCovering.Commands.GoToTiltPercentage((100 - position) * 100) + ) + async def send_device_command(self, command: Any) -> None: """Send device command.""" await self.matter_client.send_device_command( @@ -127,7 +130,9 @@ class MatterCover(MatterEntity, CoverEntity): current_cover_position = self.get_matter_attribute_value( clusters.WindowCovering.Attributes.CurrentPositionLiftPercentage ) - self._attr_current_cover_position = 100 - current_cover_position + self._attr_current_cover_position = ( + 100 - current_cover_position if current_cover_position is not None else None + ) LOGGER.debug( "Current position for %s - raw: %s - corrected: %s", @@ -136,12 +141,41 @@ class MatterCover(MatterEntity, CoverEntity): self.current_cover_position, ) + # current tilt position is inverted in matter (100 is closed, 0 is open) + current_cover_tilt_position = self.get_matter_attribute_value( + clusters.WindowCovering.Attributes.CurrentPositionTiltPercentage + ) + self._attr_current_cover_tilt_position = ( + 100 - current_cover_tilt_position + if current_cover_tilt_position is not None + else None + ) + + LOGGER.debug( + "Current tilt position for %s - raw: %s - corrected: %s", + self.entity_id, + current_cover_tilt_position, + self.current_cover_tilt_position, + ) + # map matter type to HA deviceclass device_type: clusters.WindowCovering.Enums.Type = ( self.get_matter_attribute_value(clusters.WindowCovering.Attributes.Type) ) self._attr_device_class = TYPE_MAP.get(device_type, CoverDeviceClass.AWNING) + supported_features = ( + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP + ) + commands = self.get_matter_attribute_value( + clusters.WindowCovering.Attributes.AcceptedCommandList + ) + if clusters.WindowCovering.Commands.GoToLiftPercentage.command_id in commands: + supported_features |= CoverEntityFeature.SET_POSITION + if clusters.WindowCovering.Commands.GoToTiltPercentage.command_id in commands: + supported_features |= CoverEntityFeature.SET_TILT_POSITION + self._attr_supported_features = supported_features + # Discovery schema(s) to map Matter Attributes to HA entities DISCOVERY_SCHEMAS = [ @@ -150,8 +184,51 @@ DISCOVERY_SCHEMAS = [ entity_description=CoverEntityDescription(key="MatterCover"), entity_class=MatterCover, required_attributes=( - clusters.WindowCovering.Attributes.CurrentPositionLiftPercentage, clusters.WindowCovering.Attributes.OperationalStatus, + clusters.WindowCovering.Attributes.Type, ), - ) + absent_attributes=( + clusters.WindowCovering.Attributes.CurrentPositionLiftPercentage, + clusters.WindowCovering.Attributes.CurrentPositionTiltPercentage, + ), + ), + MatterDiscoverySchema( + platform=Platform.COVER, + entity_description=CoverEntityDescription(key="MatterCoverPositionAwareLift"), + entity_class=MatterCover, + required_attributes=( + clusters.WindowCovering.Attributes.OperationalStatus, + clusters.WindowCovering.Attributes.Type, + clusters.WindowCovering.Attributes.CurrentPositionLiftPercentage, + ), + absent_attributes=( + clusters.WindowCovering.Attributes.CurrentPositionTiltPercentage, + ), + ), + MatterDiscoverySchema( + platform=Platform.COVER, + entity_description=CoverEntityDescription(key="MatterCoverPositionAwareTilt"), + entity_class=MatterCover, + required_attributes=( + clusters.WindowCovering.Attributes.OperationalStatus, + clusters.WindowCovering.Attributes.Type, + clusters.WindowCovering.Attributes.CurrentPositionTiltPercentage, + ), + absent_attributes=( + clusters.WindowCovering.Attributes.CurrentPositionLiftPercentage, + ), + ), + MatterDiscoverySchema( + platform=Platform.COVER, + entity_description=CoverEntityDescription( + key="MatterCoverPositionAwareLiftAndTilt" + ), + entity_class=MatterCover, + required_attributes=( + clusters.WindowCovering.Attributes.OperationalStatus, + clusters.WindowCovering.Attributes.Type, + clusters.WindowCovering.Attributes.CurrentPositionLiftPercentage, + clusters.WindowCovering.Attributes.CurrentPositionTiltPercentage, + ), + ), ] diff --git a/tests/components/matter/fixtures/nodes/window-covering_full.json b/tests/components/matter/fixtures/nodes/window-covering_full.json new file mode 100644 index 000000000000..3ffd68bd319b --- /dev/null +++ b/tests/components/matter/fixtures/nodes/window-covering_full.json @@ -0,0 +1,268 @@ +{ + "node_id": 50, + "date_commissioned": "2023-04-27T18:47:08.437119", + "last_interview": "2023-04-27T18:47:08.437131", + "interview_version": 3, + "available": true, + "attributes": { + "0/29/65533": 1, + "0/29/0": [ + { + "deviceType": 22, + "revision": 1 + } + ], + "0/29/1": [29, 31, 40, 48, 49, 51, 60, 62, 63, 54], + "0/29/2": [], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [65528, 65529, 65531, 65533, 0, 1, 2, 3, 65532], + "0/31/65533": 1, + "0/31/0": [ + { + "privilege": 0, + "authMode": 0, + "subjects": null, + "targets": null, + "fabricIndex": 1 + }, + { + "privilege": 0, + "authMode": 0, + "subjects": null, + "targets": null, + "fabricIndex": 2 + }, + { + "privilege": 5, + "authMode": 2, + "subjects": [112233], + "targets": null, + "fabricIndex": 3 + } + ], + "0/31/2": 4, + "0/31/4": 4, + "0/31/3": 3, + "0/31/65532": 0, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [65528, 65529, 65531, 65533, 0, 2, 4, 3, 65532], + "0/40/65532": 0, + "0/40/0": 1, + "0/40/1": "Nabu Casa", + "0/40/2": 65521, + "0/40/3": "Mock Covering", + "0/40/4": 32768, + "0/40/5": "Mock Full Window Covering", + "0/40/6": "XX", + "0/40/7": 0, + "0/40/8": "v1.0", + "0/40/9": 1, + "0/40/10": "v1.0", + "0/40/11": "20200101", + "0/40/12": "", + "0/40/13": "", + "0/40/14": "", + "0/40/15": "12345678", + "0/40/16": false, + "0/40/17": true, + "0/40/18": "mock-full-window-covering", + "0/40/19": { + "caseSessionsPerFabric": 3, + "subscriptionsPerFabric": 3 + }, + "0/40/65533": 1, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 65528, 65529, 65531, 65532, 0, 6, 1, 2, 3, 4, 7, 8, 9, 10, 19, 65533, 5 + ], + "0/48/65532": 0, + "0/48/2": 0, + "0/48/3": 0, + "0/48/1": { + "failSafeExpiryLengthSeconds": 60, + "maxCumulativeFailsafeSeconds": 900 + }, + "0/48/4": true, + "0/48/65533": 1, + "0/48/0": 0, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [65528, 65529, 65531, 65532, 2, 3, 1, 4, 65533, 0], + "0/49/0": 1, + "0/49/1": [ + { + "networkID": "MTI2MDk5", + "connected": true + } + ], + "0/49/2": 10, + "0/49/3": 30, + "0/49/4": true, + "0/49/5": 0, + "0/49/6": "MTI2MDk5", + "0/49/7": null, + "0/49/65532": 1, + "0/49/65533": 1, + "0/49/65528": [1, 5, 7], + "0/49/65529": [0, 2, 4, 6, 8], + "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], + "0/51/0": [ + { + "name": "WIFI_STA_DEF", + "isOperational": true, + "offPremiseServicesReachableIPv4": null, + "offPremiseServicesReachableIPv6": null, + "hardwareAddress": "JG8olrDo", + "IPv4Addresses": ["wKgBFw=="], + "IPv6Addresses": ["/oAAAAAAAAAmbyj//paw6A=="], + "type": 1 + } + ], + "0/51/1": 1, + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 1, + "0/51/65528": [], + "0/51/65529": [0], + "0/51/65531": [0, 1, 8, 65528, 65529, 65531, 65532, 65533], + "0/60/65532": 0, + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 1, 2], + "0/60/65531": [65528, 65529, 65531, 65532, 0, 1, 2, 65533], + "0/62/65532": 0, + "0/62/0": [ + { + "noc": "", + "icac": null, + "fabricIndex": 1 + }, + { + "noc": "", + "icac": null, + "fabricIndex": 2 + }, + { + "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRMhgkBwEkCAEwCUEE+5TLtucQZ8l7Y5r8nKhYB0mia0RMn+RJa5AtRIPb2R9ixMcQXfQBANdHPCwsfTGWyjBYzPXG1yDUTUz+Z1J9aTcKNQEoARgkAgE2AwQCBAEYMAQUh/lTccn18xJ1JqA9VRHdr2+IhscwBRTPeGj+EyBBTsdlJC4zNSP/tIcpFhgwC0AoRjZKvJRkg+Cz77N6+IIQBt0i1Oco92N/XzoDWtgUVIOW5qvPcUUI/tiYAEDdefy2/6XpjU1Y7ecN3vgoTdNUGA==", + "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEL6dfjjyZxKHsFjZvYUOhWsOCI/2ucOxcCZGFaJwG0vXhL5/aDhR/AF907lF93LR1Huvp3NJsB0oxqsNnbEz8jjcKNQEpARgkAmAwBBTPeGj+EyBBTsdlJC4zNSP/tIcpFjAFFC8Br9IClyBL3e7po3G+QXNGsBoYGDALQIHEwwdIaYHnFzpYngW9g+7Cn3gl0qKnetK5gWUVVTdVtpx6dYBblvPnOU+5K3Ow85llzcRxU1yXgPAM77s7t8gY", + "fabricIndex": 3 + } + ], + "0/62/2": 5, + "0/62/3": 3, + "0/62/1": [ + { + "rootPublicKey": "BFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U=", + "vendorId": 24582, + "fabricId": 7331465149450221740, + "nodeId": 3429688654, + "label": "", + "fabricIndex": 1 + }, + { + "rootPublicKey": "BJyJ1DODbJ+HellxuG3J/EstNpyw/i5h1x5qjNLQjwnPZoEaLLMZ8KKN7/rxQy3JUIkfuQydJz7JXeF80mES8q8=", + "vendorId": 4362, + "fabricId": 8516517930550670493, + "nodeId": 1443093566726981311, + "label": "", + "fabricIndex": 2 + }, + { + "rootPublicKey": "BFOpRqEk+HJ6n/NtUtaWTQVVwstz9QRDK2xvRP6qKZKX3Rk05Zie5Ux9PdjgE1K5zE9NIP2jHHcVJjRBVZxNFz0=", + "vendorId": 4939, + "fabricId": 2, + "nodeId": 50, + "label": "", + "fabricIndex": 3 + } + ], + "0/62/4": [ + "FTABAQEkAgE3AyyEAlVTLAcGR29vZ2xlLAELTWF0dGVyIFJvb3QnFAEAAAD+////GCYEf9JDKSYFf5Rb5TcGLIQCVVMsBwZHb29nbGUsAQtNYXR0ZXIgUm9vdCcUAQAAAP7///8YJAcBJAgBMAlBBFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U3CjUBKQEkAgEYJAJgMAQUcsIB91cZE7NIygDKe0X0d0ZoyX4wBRRywgH3VxkTs0jKAMp7RfR3RmjJfhgwC0BlFksWat/xjBVhCozpG9cD6cH2d7cRzhM1BRUt8NoVERZ1rFWRzueGhRzdnv2tKWZ0vryyo6Mgm83nswnbVSxvGA==", + "FTABEQDNbArxrUcyIYFkHqJLbo/WJAIBNwMnFHQ/DS4Kfg0eGCYEt4c/KyYFx+imNDcGJxR0Pw0uCn4NHhgkBwEkCAEwCUEEnInUM4Nsn4d6WXG4bcn8Sy02nLD+LmHXHmqM0tCPCc9mgRossxnwoo3v+vFDLclQiR+5DJ0nPsld4XzSYRLyrzcKNQEpARgwBBRmfAEklIwuWsdBmLyvfEmBToSQbyQCYTAFFGZ8ASSUjC5ax0GYvK98SYFOhJBvGDALQG4YnkZOq0UG+s4+HLs3eTnu91x/TgO5aeFb7sRom4xWTHn+rS7KISVXeWs6W3FRJsJdyZSYNgdwv5/cRFPC5q8Y", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEU6lGoST4cnqf821S1pZNBVXCy3P1BEMrbG9E/qopkpfdGTTlmJ7lTH092OATUrnMT00g/aMcdxUmNEFVnE0XPTcKNQEpARgkAmAwBBQvAa/SApcgS93u6aNxvkFzRrAaGDAFFC8Br9IClyBL3e7po3G+QXNGsBoYGDALQJjFHQ5JVpar4vSHdn0lTZreXN7Ye9N3BfZWW+BJXbTs9yOH/oON1B1OLipahkwhWRj7A26vEfRHQ9g3Bh2Zt/kY" + ], + "0/62/5": 3, + "0/62/65533": 1, + "0/62/65528": [1, 3, 5, 8], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65531": [65528, 65529, 65531, 65532, 0, 2, 3, 1, 4, 5, 65533], + "0/63/65532": 0, + "0/63/65533": 1, + "0/63/0": [], + "0/63/1": [], + "0/63/2": 4, + "0/63/3": 3, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [65528, 65529, 65531, 65532, 65533, 0, 1, 2, 3], + "0/54/65532": 0, + "0/54/0": "YI0m7T6v", + "0/54/1": 4, + "0/54/2": 3, + "0/54/3": 6, + "0/54/4": -88, + "0/54/65533": 1, + "0/54/65528": [], + "0/54/65529": [], + "0/54/65531": [65528, 65529, 65531, 65532, 0, 1, 2, 3, 4, 65533], + "1/29/65533": 1, + "1/29/0": [ + { + "deviceType": 514, + "revision": 2 + } + ], + "1/29/1": [29, 3, 258], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [65528, 65529, 65531, 65533, 0, 1, 2, 3, 65532], + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/0": 0, + "1/3/1": 0, + "1/3/65528": [], + "1/3/65529": [0], + "1/3/65531": [65528, 65529, 65531, 65532, 65533, 0, 1], + "1/258/65532": 31, + "1/258/65533": 5, + "1/258/0": 6, + "1/258/1": 90, + "1/258/2": 90, + "1/258/3": 0, + "1/258/4": 0, + "1/258/5": 0, + "1/258/7": 1, + "1/258/8": 0, + "1/258/9": 0, + "1/258/10": 0, + "1/258/11": 0, + "1/258/12": 0, + "1/258/13": 255, + "1/258/14": 0, + "1/258/15": 0, + "1/258/16": 0, + "1/258/17": 0, + "1/258/18": 0, + "1/258/19": 0, + "1/258/23": 0, + "1/258/26": 0, + "1/258/6": 0, + "1/258/65528": [], + "1/258/65529": [0, 1, 2, 4, 5, 7, 8], + "1/258/65531": [ + 65528, 65529, 65531, 65532, 65533, 0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, + 13, 14, 15, 16, 17, 18, 19, 23, 26, 6 + ] + } +} diff --git a/tests/components/matter/fixtures/nodes/window-covering_lift.json b/tests/components/matter/fixtures/nodes/window-covering_lift.json new file mode 100644 index 000000000000..cd200eb15f01 --- /dev/null +++ b/tests/components/matter/fixtures/nodes/window-covering_lift.json @@ -0,0 +1,249 @@ +{ + "node_id": 50, + "date_commissioned": "2023-04-27T18:47:08.437119", + "last_interview": "2023-04-27T18:47:08.437131", + "interview_version": 3, + "available": true, + "attributes": { + "0/29/65533": 1, + "0/29/0": [ + { + "deviceType": 22, + "revision": 1 + } + ], + "0/29/1": [29, 31, 40, 48, 49, 51, 60, 62, 63, 54], + "0/29/2": [], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [65528, 65529, 65531, 65533, 0, 1, 2, 3, 65532], + "0/31/65533": 1, + "0/31/0": [ + { + "privilege": 0, + "authMode": 0, + "subjects": null, + "targets": null, + "fabricIndex": 1 + }, + { + "privilege": 0, + "authMode": 0, + "subjects": null, + "targets": null, + "fabricIndex": 2 + }, + { + "privilege": 5, + "authMode": 2, + "subjects": [112233], + "targets": null, + "fabricIndex": 3 + } + ], + "0/31/2": 4, + "0/31/4": 4, + "0/31/3": 3, + "0/31/65532": 0, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [65528, 65529, 65531, 65533, 0, 2, 4, 3, 65532], + "0/40/65532": 0, + "0/40/0": 1, + "0/40/1": "Nabu Casa", + "0/40/2": 65521, + "0/40/3": "Mock Covering", + "0/40/4": 32768, + "0/40/5": "Mock Lift Window Covering", + "0/40/6": "XX", + "0/40/7": 0, + "0/40/8": "v1.0", + "0/40/9": 1, + "0/40/10": "v1.0", + "0/40/11": "20200101", + "0/40/12": "", + "0/40/13": "", + "0/40/14": "", + "0/40/15": "12345678", + "0/40/16": false, + "0/40/17": true, + "0/40/18": "mock-lift-window-covering", + "0/40/19": { + "caseSessionsPerFabric": 3, + "subscriptionsPerFabric": 3 + }, + "0/40/65533": 1, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 65528, 65529, 65531, 65532, 0, 6, 1, 2, 3, 4, 7, 8, 9, 10, 19, 65533, 5 + ], + "0/48/65532": 0, + "0/48/2": 0, + "0/48/3": 0, + "0/48/1": { + "failSafeExpiryLengthSeconds": 60, + "maxCumulativeFailsafeSeconds": 900 + }, + "0/48/4": true, + "0/48/65533": 1, + "0/48/0": 0, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [65528, 65529, 65531, 65532, 2, 3, 1, 4, 65533, 0], + "0/49/0": 1, + "0/49/1": [ + { + "networkID": "MTI2MDk5", + "connected": true + } + ], + "0/49/2": 10, + "0/49/3": 30, + "0/49/4": true, + "0/49/5": 0, + "0/49/6": "MTI2MDk5", + "0/49/7": null, + "0/49/65532": 1, + "0/49/65533": 1, + "0/49/65528": [1, 5, 7], + "0/49/65529": [0, 2, 4, 6, 8], + "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], + "0/51/0": [ + { + "name": "WIFI_STA_DEF", + "isOperational": true, + "offPremiseServicesReachableIPv4": null, + "offPremiseServicesReachableIPv6": null, + "hardwareAddress": "JG8olrDo", + "IPv4Addresses": ["wKgBFw=="], + "IPv6Addresses": ["/oAAAAAAAAAmbyj//paw6A=="], + "type": 1 + } + ], + "0/51/1": 1, + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 1, + "0/51/65528": [], + "0/51/65529": [0], + "0/51/65531": [0, 1, 8, 65528, 65529, 65531, 65532, 65533], + "0/60/65532": 0, + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 1, 2], + "0/60/65531": [65528, 65529, 65531, 65532, 0, 1, 2, 65533], + "0/62/65532": 0, + "0/62/0": [ + { + "noc": "", + "icac": null, + "fabricIndex": 1 + }, + { + "noc": "", + "icac": null, + "fabricIndex": 2 + }, + { + "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRMhgkBwEkCAEwCUEE+5TLtucQZ8l7Y5r8nKhYB0mia0RMn+RJa5AtRIPb2R9ixMcQXfQBANdHPCwsfTGWyjBYzPXG1yDUTUz+Z1J9aTcKNQEoARgkAgE2AwQCBAEYMAQUh/lTccn18xJ1JqA9VRHdr2+IhscwBRTPeGj+EyBBTsdlJC4zNSP/tIcpFhgwC0AoRjZKvJRkg+Cz77N6+IIQBt0i1Oco92N/XzoDWtgUVIOW5qvPcUUI/tiYAEDdefy2/6XpjU1Y7ecN3vgoTdNUGA==", + "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEL6dfjjyZxKHsFjZvYUOhWsOCI/2ucOxcCZGFaJwG0vXhL5/aDhR/AF907lF93LR1Huvp3NJsB0oxqsNnbEz8jjcKNQEpARgkAmAwBBTPeGj+EyBBTsdlJC4zNSP/tIcpFjAFFC8Br9IClyBL3e7po3G+QXNGsBoYGDALQIHEwwdIaYHnFzpYngW9g+7Cn3gl0qKnetK5gWUVVTdVtpx6dYBblvPnOU+5K3Ow85llzcRxU1yXgPAM77s7t8gY", + "fabricIndex": 3 + } + ], + "0/62/2": 5, + "0/62/3": 3, + "0/62/1": [ + { + "rootPublicKey": "BFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U=", + "vendorId": 24582, + "fabricId": 7331465149450221740, + "nodeId": 3429688654, + "label": "", + "fabricIndex": 1 + }, + { + "rootPublicKey": "BJyJ1DODbJ+HellxuG3J/EstNpyw/i5h1x5qjNLQjwnPZoEaLLMZ8KKN7/rxQy3JUIkfuQydJz7JXeF80mES8q8=", + "vendorId": 4362, + "fabricId": 8516517930550670493, + "nodeId": 1443093566726981311, + "label": "", + "fabricIndex": 2 + }, + { + "rootPublicKey": "BFOpRqEk+HJ6n/NtUtaWTQVVwstz9QRDK2xvRP6qKZKX3Rk05Zie5Ux9PdjgE1K5zE9NIP2jHHcVJjRBVZxNFz0=", + "vendorId": 4939, + "fabricId": 2, + "nodeId": 50, + "label": "", + "fabricIndex": 3 + } + ], + "0/62/4": [ + "FTABAQEkAgE3AyyEAlVTLAcGR29vZ2xlLAELTWF0dGVyIFJvb3QnFAEAAAD+////GCYEf9JDKSYFf5Rb5TcGLIQCVVMsBwZHb29nbGUsAQtNYXR0ZXIgUm9vdCcUAQAAAP7///8YJAcBJAgBMAlBBFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U3CjUBKQEkAgEYJAJgMAQUcsIB91cZE7NIygDKe0X0d0ZoyX4wBRRywgH3VxkTs0jKAMp7RfR3RmjJfhgwC0BlFksWat/xjBVhCozpG9cD6cH2d7cRzhM1BRUt8NoVERZ1rFWRzueGhRzdnv2tKWZ0vryyo6Mgm83nswnbVSxvGA==", + "FTABEQDNbArxrUcyIYFkHqJLbo/WJAIBNwMnFHQ/DS4Kfg0eGCYEt4c/KyYFx+imNDcGJxR0Pw0uCn4NHhgkBwEkCAEwCUEEnInUM4Nsn4d6WXG4bcn8Sy02nLD+LmHXHmqM0tCPCc9mgRossxnwoo3v+vFDLclQiR+5DJ0nPsld4XzSYRLyrzcKNQEpARgwBBRmfAEklIwuWsdBmLyvfEmBToSQbyQCYTAFFGZ8ASSUjC5ax0GYvK98SYFOhJBvGDALQG4YnkZOq0UG+s4+HLs3eTnu91x/TgO5aeFb7sRom4xWTHn+rS7KISVXeWs6W3FRJsJdyZSYNgdwv5/cRFPC5q8Y", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEU6lGoST4cnqf821S1pZNBVXCy3P1BEMrbG9E/qopkpfdGTTlmJ7lTH092OATUrnMT00g/aMcdxUmNEFVnE0XPTcKNQEpARgkAmAwBBQvAa/SApcgS93u6aNxvkFzRrAaGDAFFC8Br9IClyBL3e7po3G+QXNGsBoYGDALQJjFHQ5JVpar4vSHdn0lTZreXN7Ye9N3BfZWW+BJXbTs9yOH/oON1B1OLipahkwhWRj7A26vEfRHQ9g3Bh2Zt/kY" + ], + "0/62/5": 3, + "0/62/65533": 1, + "0/62/65528": [1, 3, 5, 8], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65531": [65528, 65529, 65531, 65532, 0, 2, 3, 1, 4, 5, 65533], + "0/63/65532": 0, + "0/63/65533": 1, + "0/63/0": [], + "0/63/1": [], + "0/63/2": 4, + "0/63/3": 3, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [65528, 65529, 65531, 65532, 65533, 0, 1, 2, 3], + "0/54/65532": 0, + "0/54/0": "YI0m7T6v", + "0/54/1": 4, + "0/54/2": 3, + "0/54/3": 6, + "0/54/4": -88, + "0/54/65533": 1, + "0/54/65528": [], + "0/54/65529": [], + "0/54/65531": [65528, 65529, 65531, 65532, 0, 1, 2, 3, 4, 65533], + "1/29/65533": 1, + "1/29/0": [ + { + "deviceType": 514, + "revision": 2 + } + ], + "1/29/1": [29, 3, 258], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [65528, 65529, 65531, 65533, 0, 1, 2, 3, 65532], + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/0": 0, + "1/3/1": 0, + "1/3/65528": [], + "1/3/65529": [0], + "1/3/65531": [65528, 65529, 65531, 65532, 65533, 0, 1], + "1/258/65532": 1, + "1/258/65533": 5, + "1/258/0": 5, + "1/258/5": 0, + "1/258/7": 1, + "1/258/10": 0, + "1/258/13": 255, + "1/258/23": 0, + "1/258/65528": [], + "1/258/65529": [0, 1, 2, 5], + "1/258/65531": [65528, 65529, 65531, 65532, 65533, 0, 1, 5, 7, 10, 13, 23] + } +} diff --git a/tests/components/matter/fixtures/nodes/window-covering.json b/tests/components/matter/fixtures/nodes/window-covering_pa-lift.json similarity index 100% rename from tests/components/matter/fixtures/nodes/window-covering.json rename to tests/components/matter/fixtures/nodes/window-covering_pa-lift.json diff --git a/tests/components/matter/fixtures/nodes/window-covering_pa-tilt.json b/tests/components/matter/fixtures/nodes/window-covering_pa-tilt.json new file mode 100644 index 000000000000..d432d7cf50a7 --- /dev/null +++ b/tests/components/matter/fixtures/nodes/window-covering_pa-tilt.json @@ -0,0 +1,254 @@ +{ + "node_id": 50, + "date_commissioned": "2023-04-27T18:47:08.437119", + "last_interview": "2023-04-27T18:47:08.437131", + "interview_version": 3, + "available": true, + "attributes": { + "0/29/65533": 1, + "0/29/0": [ + { + "deviceType": 22, + "revision": 1 + } + ], + "0/29/1": [29, 31, 40, 48, 49, 51, 60, 62, 63, 54], + "0/29/2": [], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [65528, 65529, 65531, 65533, 0, 1, 2, 3, 65532], + "0/31/65533": 1, + "0/31/0": [ + { + "privilege": 0, + "authMode": 0, + "subjects": null, + "targets": null, + "fabricIndex": 1 + }, + { + "privilege": 0, + "authMode": 0, + "subjects": null, + "targets": null, + "fabricIndex": 2 + }, + { + "privilege": 5, + "authMode": 2, + "subjects": [112233], + "targets": null, + "fabricIndex": 3 + } + ], + "0/31/2": 4, + "0/31/4": 4, + "0/31/3": 3, + "0/31/65532": 0, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [65528, 65529, 65531, 65533, 0, 2, 4, 3, 65532], + "0/40/65532": 0, + "0/40/0": 1, + "0/40/1": "Nabu Casa", + "0/40/2": 65521, + "0/40/3": "Mock Covering", + "0/40/4": 32768, + "0/40/5": "Mock PA Tilt Window Covering", + "0/40/6": "XX", + "0/40/7": 0, + "0/40/8": "v1.0", + "0/40/9": 1, + "0/40/10": "v1.0", + "0/40/11": "20200101", + "0/40/12": "", + "0/40/13": "", + "0/40/14": "", + "0/40/15": "12345678", + "0/40/16": false, + "0/40/17": true, + "0/40/18": "mock_pa_tilt_window_covering", + "0/40/19": { + "caseSessionsPerFabric": 3, + "subscriptionsPerFabric": 3 + }, + "0/40/65533": 1, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 65528, 65529, 65531, 65532, 0, 6, 1, 2, 3, 4, 7, 8, 9, 10, 19, 65533, 5 + ], + "0/48/65532": 0, + "0/48/2": 0, + "0/48/3": 0, + "0/48/1": { + "failSafeExpiryLengthSeconds": 60, + "maxCumulativeFailsafeSeconds": 900 + }, + "0/48/4": true, + "0/48/65533": 1, + "0/48/0": 0, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [65528, 65529, 65531, 65532, 2, 3, 1, 4, 65533, 0], + "0/49/0": 1, + "0/49/1": [ + { + "networkID": "MTI2MDk5", + "connected": true + } + ], + "0/49/2": 10, + "0/49/3": 30, + "0/49/4": true, + "0/49/5": 0, + "0/49/6": "MTI2MDk5", + "0/49/7": null, + "0/49/65532": 1, + "0/49/65533": 1, + "0/49/65528": [1, 5, 7], + "0/49/65529": [0, 2, 4, 6, 8], + "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], + "0/51/0": [ + { + "name": "WIFI_STA_DEF", + "isOperational": true, + "offPremiseServicesReachableIPv4": null, + "offPremiseServicesReachableIPv6": null, + "hardwareAddress": "JG8olrDo", + "IPv4Addresses": ["wKgBFw=="], + "IPv6Addresses": ["/oAAAAAAAAAmbyj//paw6A=="], + "type": 1 + } + ], + "0/51/1": 1, + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 1, + "0/51/65528": [], + "0/51/65529": [0], + "0/51/65531": [0, 1, 8, 65528, 65529, 65531, 65532, 65533], + "0/60/65532": 0, + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 1, 2], + "0/60/65531": [65528, 65529, 65531, 65532, 0, 1, 2, 65533], + "0/62/65532": 0, + "0/62/0": [ + { + "noc": "", + "icac": null, + "fabricIndex": 1 + }, + { + "noc": "", + "icac": null, + "fabricIndex": 2 + }, + { + "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRMhgkBwEkCAEwCUEE+5TLtucQZ8l7Y5r8nKhYB0mia0RMn+RJa5AtRIPb2R9ixMcQXfQBANdHPCwsfTGWyjBYzPXG1yDUTUz+Z1J9aTcKNQEoARgkAgE2AwQCBAEYMAQUh/lTccn18xJ1JqA9VRHdr2+IhscwBRTPeGj+EyBBTsdlJC4zNSP/tIcpFhgwC0AoRjZKvJRkg+Cz77N6+IIQBt0i1Oco92N/XzoDWtgUVIOW5qvPcUUI/tiYAEDdefy2/6XpjU1Y7ecN3vgoTdNUGA==", + "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEL6dfjjyZxKHsFjZvYUOhWsOCI/2ucOxcCZGFaJwG0vXhL5/aDhR/AF907lF93LR1Huvp3NJsB0oxqsNnbEz8jjcKNQEpARgkAmAwBBTPeGj+EyBBTsdlJC4zNSP/tIcpFjAFFC8Br9IClyBL3e7po3G+QXNGsBoYGDALQIHEwwdIaYHnFzpYngW9g+7Cn3gl0qKnetK5gWUVVTdVtpx6dYBblvPnOU+5K3Ow85llzcRxU1yXgPAM77s7t8gY", + "fabricIndex": 3 + } + ], + "0/62/2": 5, + "0/62/3": 3, + "0/62/1": [ + { + "rootPublicKey": "BFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U=", + "vendorId": 24582, + "fabricId": 7331465149450221740, + "nodeId": 3429688654, + "label": "", + "fabricIndex": 1 + }, + { + "rootPublicKey": "BJyJ1DODbJ+HellxuG3J/EstNpyw/i5h1x5qjNLQjwnPZoEaLLMZ8KKN7/rxQy3JUIkfuQydJz7JXeF80mES8q8=", + "vendorId": 4362, + "fabricId": 8516517930550670493, + "nodeId": 1443093566726981311, + "label": "", + "fabricIndex": 2 + }, + { + "rootPublicKey": "BFOpRqEk+HJ6n/NtUtaWTQVVwstz9QRDK2xvRP6qKZKX3Rk05Zie5Ux9PdjgE1K5zE9NIP2jHHcVJjRBVZxNFz0=", + "vendorId": 4939, + "fabricId": 2, + "nodeId": 50, + "label": "", + "fabricIndex": 3 + } + ], + "0/62/4": [ + "FTABAQEkAgE3AyyEAlVTLAcGR29vZ2xlLAELTWF0dGVyIFJvb3QnFAEAAAD+////GCYEf9JDKSYFf5Rb5TcGLIQCVVMsBwZHb29nbGUsAQtNYXR0ZXIgUm9vdCcUAQAAAP7///8YJAcBJAgBMAlBBFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U3CjUBKQEkAgEYJAJgMAQUcsIB91cZE7NIygDKe0X0d0ZoyX4wBRRywgH3VxkTs0jKAMp7RfR3RmjJfhgwC0BlFksWat/xjBVhCozpG9cD6cH2d7cRzhM1BRUt8NoVERZ1rFWRzueGhRzdnv2tKWZ0vryyo6Mgm83nswnbVSxvGA==", + "FTABEQDNbArxrUcyIYFkHqJLbo/WJAIBNwMnFHQ/DS4Kfg0eGCYEt4c/KyYFx+imNDcGJxR0Pw0uCn4NHhgkBwEkCAEwCUEEnInUM4Nsn4d6WXG4bcn8Sy02nLD+LmHXHmqM0tCPCc9mgRossxnwoo3v+vFDLclQiR+5DJ0nPsld4XzSYRLyrzcKNQEpARgwBBRmfAEklIwuWsdBmLyvfEmBToSQbyQCYTAFFGZ8ASSUjC5ax0GYvK98SYFOhJBvGDALQG4YnkZOq0UG+s4+HLs3eTnu91x/TgO5aeFb7sRom4xWTHn+rS7KISVXeWs6W3FRJsJdyZSYNgdwv5/cRFPC5q8Y", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEU6lGoST4cnqf821S1pZNBVXCy3P1BEMrbG9E/qopkpfdGTTlmJ7lTH092OATUrnMT00g/aMcdxUmNEFVnE0XPTcKNQEpARgkAmAwBBQvAa/SApcgS93u6aNxvkFzRrAaGDAFFC8Br9IClyBL3e7po3G+QXNGsBoYGDALQJjFHQ5JVpar4vSHdn0lTZreXN7Ye9N3BfZWW+BJXbTs9yOH/oON1B1OLipahkwhWRj7A26vEfRHQ9g3Bh2Zt/kY" + ], + "0/62/5": 3, + "0/62/65533": 1, + "0/62/65528": [1, 3, 5, 8], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65531": [65528, 65529, 65531, 65532, 0, 2, 3, 1, 4, 5, 65533], + "0/63/65532": 0, + "0/63/65533": 1, + "0/63/0": [], + "0/63/1": [], + "0/63/2": 4, + "0/63/3": 3, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [65528, 65529, 65531, 65532, 65533, 0, 1, 2, 3], + "0/54/65532": 0, + "0/54/0": "YI0m7T6v", + "0/54/1": 4, + "0/54/2": 3, + "0/54/3": 6, + "0/54/4": -88, + "0/54/65533": 1, + "0/54/65528": [], + "0/54/65529": [], + "0/54/65531": [65528, 65529, 65531, 65532, 0, 1, 2, 3, 4, 65533], + "1/29/65533": 1, + "1/29/0": [ + { + "deviceType": 514, + "revision": 2 + } + ], + "1/29/1": [29, 3, 258], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [65528, 65529, 65531, 65533, 0, 1, 2, 3, 65532], + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/0": 0, + "1/3/1": 0, + "1/3/65528": [], + "1/3/65529": [0], + "1/3/65531": [65528, 65529, 65531, 65532, 65533, 0, 1], + "1/258/65532": 18, + "1/258/65533": 5, + "1/258/0": 7, + "1/258/7": 1, + "1/258/9": 0, + "1/258/10": 0, + "1/258/12": 0, + "1/258/13": 255, + "1/258/15": 0, + "1/258/23": 0, + "1/258/6": 0, + "1/258/65528": [], + "1/258/65529": [0, 1, 2, 8], + "1/258/65531": [ + 65528, 65529, 65531, 65532, 65533, 0, 7, 9, 10, 12, 13, 15, 23, 6 + ] + } +} diff --git a/tests/components/matter/fixtures/nodes/window-covering_tilt.json b/tests/components/matter/fixtures/nodes/window-covering_tilt.json new file mode 100644 index 000000000000..1fe0d99ac38f --- /dev/null +++ b/tests/components/matter/fixtures/nodes/window-covering_tilt.json @@ -0,0 +1,249 @@ +{ + "node_id": 50, + "date_commissioned": "2023-04-27T18:47:08.437119", + "last_interview": "2023-04-27T18:47:08.437131", + "interview_version": 3, + "available": true, + "attributes": { + "0/29/65533": 1, + "0/29/0": [ + { + "deviceType": 22, + "revision": 1 + } + ], + "0/29/1": [29, 31, 40, 48, 49, 51, 60, 62, 63, 54], + "0/29/2": [], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [65528, 65529, 65531, 65533, 0, 1, 2, 3, 65532], + "0/31/65533": 1, + "0/31/0": [ + { + "privilege": 0, + "authMode": 0, + "subjects": null, + "targets": null, + "fabricIndex": 1 + }, + { + "privilege": 0, + "authMode": 0, + "subjects": null, + "targets": null, + "fabricIndex": 2 + }, + { + "privilege": 5, + "authMode": 2, + "subjects": [112233], + "targets": null, + "fabricIndex": 3 + } + ], + "0/31/2": 4, + "0/31/4": 4, + "0/31/3": 3, + "0/31/65532": 0, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [65528, 65529, 65531, 65533, 0, 2, 4, 3, 65532], + "0/40/65532": 0, + "0/40/0": 1, + "0/40/1": "Nabu Casa", + "0/40/2": 65521, + "0/40/3": "Mock Covering", + "0/40/4": 32768, + "0/40/5": "Mock Tilt Window Covering", + "0/40/6": "XX", + "0/40/7": 0, + "0/40/8": "v1.0", + "0/40/9": 1, + "0/40/10": "v1.0", + "0/40/11": "20200101", + "0/40/12": "", + "0/40/13": "", + "0/40/14": "", + "0/40/15": "12345678", + "0/40/16": false, + "0/40/17": true, + "0/40/18": "mock-tilt-window-covering", + "0/40/19": { + "caseSessionsPerFabric": 3, + "subscriptionsPerFabric": 3 + }, + "0/40/65533": 1, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 65528, 65529, 65531, 65532, 0, 6, 1, 2, 3, 4, 7, 8, 9, 10, 19, 65533, 5 + ], + "0/48/65532": 0, + "0/48/2": 0, + "0/48/3": 0, + "0/48/1": { + "failSafeExpiryLengthSeconds": 60, + "maxCumulativeFailsafeSeconds": 900 + }, + "0/48/4": true, + "0/48/65533": 1, + "0/48/0": 0, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [65528, 65529, 65531, 65532, 2, 3, 1, 4, 65533, 0], + "0/49/0": 1, + "0/49/1": [ + { + "networkID": "MTI2MDk5", + "connected": true + } + ], + "0/49/2": 10, + "0/49/3": 30, + "0/49/4": true, + "0/49/5": 0, + "0/49/6": "MTI2MDk5", + "0/49/7": null, + "0/49/65532": 1, + "0/49/65533": 1, + "0/49/65528": [1, 5, 7], + "0/49/65529": [0, 2, 4, 6, 8], + "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], + "0/51/0": [ + { + "name": "WIFI_STA_DEF", + "isOperational": true, + "offPremiseServicesReachableIPv4": null, + "offPremiseServicesReachableIPv6": null, + "hardwareAddress": "JG8olrDo", + "IPv4Addresses": ["wKgBFw=="], + "IPv6Addresses": ["/oAAAAAAAAAmbyj//paw6A=="], + "type": 1 + } + ], + "0/51/1": 1, + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 1, + "0/51/65528": [], + "0/51/65529": [0], + "0/51/65531": [0, 1, 8, 65528, 65529, 65531, 65532, 65533], + "0/60/65532": 0, + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 1, 2], + "0/60/65531": [65528, 65529, 65531, 65532, 0, 1, 2, 65533], + "0/62/65532": 0, + "0/62/0": [ + { + "noc": "", + "icac": null, + "fabricIndex": 1 + }, + { + "noc": "", + "icac": null, + "fabricIndex": 2 + }, + { + "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRMhgkBwEkCAEwCUEE+5TLtucQZ8l7Y5r8nKhYB0mia0RMn+RJa5AtRIPb2R9ixMcQXfQBANdHPCwsfTGWyjBYzPXG1yDUTUz+Z1J9aTcKNQEoARgkAgE2AwQCBAEYMAQUh/lTccn18xJ1JqA9VRHdr2+IhscwBRTPeGj+EyBBTsdlJC4zNSP/tIcpFhgwC0AoRjZKvJRkg+Cz77N6+IIQBt0i1Oco92N/XzoDWtgUVIOW5qvPcUUI/tiYAEDdefy2/6XpjU1Y7ecN3vgoTdNUGA==", + "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEL6dfjjyZxKHsFjZvYUOhWsOCI/2ucOxcCZGFaJwG0vXhL5/aDhR/AF907lF93LR1Huvp3NJsB0oxqsNnbEz8jjcKNQEpARgkAmAwBBTPeGj+EyBBTsdlJC4zNSP/tIcpFjAFFC8Br9IClyBL3e7po3G+QXNGsBoYGDALQIHEwwdIaYHnFzpYngW9g+7Cn3gl0qKnetK5gWUVVTdVtpx6dYBblvPnOU+5K3Ow85llzcRxU1yXgPAM77s7t8gY", + "fabricIndex": 3 + } + ], + "0/62/2": 5, + "0/62/3": 3, + "0/62/1": [ + { + "rootPublicKey": "BFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U=", + "vendorId": 24582, + "fabricId": 7331465149450221740, + "nodeId": 3429688654, + "label": "", + "fabricIndex": 1 + }, + { + "rootPublicKey": "BJyJ1DODbJ+HellxuG3J/EstNpyw/i5h1x5qjNLQjwnPZoEaLLMZ8KKN7/rxQy3JUIkfuQydJz7JXeF80mES8q8=", + "vendorId": 4362, + "fabricId": 8516517930550670493, + "nodeId": 1443093566726981311, + "label": "", + "fabricIndex": 2 + }, + { + "rootPublicKey": "BFOpRqEk+HJ6n/NtUtaWTQVVwstz9QRDK2xvRP6qKZKX3Rk05Zie5Ux9PdjgE1K5zE9NIP2jHHcVJjRBVZxNFz0=", + "vendorId": 4939, + "fabricId": 2, + "nodeId": 50, + "label": "", + "fabricIndex": 3 + } + ], + "0/62/4": [ + "FTABAQEkAgE3AyyEAlVTLAcGR29vZ2xlLAELTWF0dGVyIFJvb3QnFAEAAAD+////GCYEf9JDKSYFf5Rb5TcGLIQCVVMsBwZHb29nbGUsAQtNYXR0ZXIgUm9vdCcUAQAAAP7///8YJAcBJAgBMAlBBFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U3CjUBKQEkAgEYJAJgMAQUcsIB91cZE7NIygDKe0X0d0ZoyX4wBRRywgH3VxkTs0jKAMp7RfR3RmjJfhgwC0BlFksWat/xjBVhCozpG9cD6cH2d7cRzhM1BRUt8NoVERZ1rFWRzueGhRzdnv2tKWZ0vryyo6Mgm83nswnbVSxvGA==", + "FTABEQDNbArxrUcyIYFkHqJLbo/WJAIBNwMnFHQ/DS4Kfg0eGCYEt4c/KyYFx+imNDcGJxR0Pw0uCn4NHhgkBwEkCAEwCUEEnInUM4Nsn4d6WXG4bcn8Sy02nLD+LmHXHmqM0tCPCc9mgRossxnwoo3v+vFDLclQiR+5DJ0nPsld4XzSYRLyrzcKNQEpARgwBBRmfAEklIwuWsdBmLyvfEmBToSQbyQCYTAFFGZ8ASSUjC5ax0GYvK98SYFOhJBvGDALQG4YnkZOq0UG+s4+HLs3eTnu91x/TgO5aeFb7sRom4xWTHn+rS7KISVXeWs6W3FRJsJdyZSYNgdwv5/cRFPC5q8Y", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEU6lGoST4cnqf821S1pZNBVXCy3P1BEMrbG9E/qopkpfdGTTlmJ7lTH092OATUrnMT00g/aMcdxUmNEFVnE0XPTcKNQEpARgkAmAwBBQvAa/SApcgS93u6aNxvkFzRrAaGDAFFC8Br9IClyBL3e7po3G+QXNGsBoYGDALQJjFHQ5JVpar4vSHdn0lTZreXN7Ye9N3BfZWW+BJXbTs9yOH/oON1B1OLipahkwhWRj7A26vEfRHQ9g3Bh2Zt/kY" + ], + "0/62/5": 3, + "0/62/65533": 1, + "0/62/65528": [1, 3, 5, 8], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65531": [65528, 65529, 65531, 65532, 0, 2, 3, 1, 4, 5, 65533], + "0/63/65532": 0, + "0/63/65533": 1, + "0/63/0": [], + "0/63/1": [], + "0/63/2": 4, + "0/63/3": 3, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [65528, 65529, 65531, 65532, 65533, 0, 1, 2, 3], + "0/54/65532": 0, + "0/54/0": "YI0m7T6v", + "0/54/1": 4, + "0/54/2": 3, + "0/54/3": 6, + "0/54/4": -88, + "0/54/65533": 1, + "0/54/65528": [], + "0/54/65529": [], + "0/54/65531": [65528, 65529, 65531, 65532, 0, 1, 2, 3, 4, 65533], + "1/29/65533": 1, + "1/29/0": [ + { + "deviceType": 514, + "revision": 2 + } + ], + "1/29/1": [29, 3, 258], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [65528, 65529, 65531, 65533, 0, 1, 2, 3, 65532], + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/0": 0, + "1/3/1": 0, + "1/3/65528": [], + "1/3/65529": [0], + "1/3/65531": [65528, 65529, 65531, 65532, 65533, 0, 1], + "1/258/65532": 2, + "1/258/65533": 5, + "1/258/0": 7, + "1/258/6": 0, + "1/258/7": 1, + "1/258/10": 0, + "1/258/13": 255, + "1/258/23": 0, + "1/258/65528": [], + "1/258/65529": [0, 1, 2, 8], + "1/258/65531": [65528, 65529, 65531, 65532, 65533, 0, 7, 10, 13, 23, 6] + } +} diff --git a/tests/components/matter/test_cover.py b/tests/components/matter/test_cover.py index 15ce8ceea8b0..bbbd0751e4a7 100644 --- a/tests/components/matter/test_cover.py +++ b/tests/components/matter/test_cover.py @@ -2,7 +2,6 @@ from unittest.mock import MagicMock, call from chip.clusters import Objects as clusters -from matter_server.client.models.node import MatterNode import pytest from homeassistant.components.cover import ( @@ -10,6 +9,7 @@ from homeassistant.components.cover import ( STATE_CLOSING, STATE_OPEN, STATE_OPENING, + CoverEntityFeature, ) from homeassistant.core import HomeAssistant @@ -20,29 +20,37 @@ from .common import ( ) -@pytest.fixture(name="window_covering") -async def window_covering_fixture( - hass: HomeAssistant, matter_client: MagicMock -) -> MatterNode: - """Fixture for a window covering node.""" - return await setup_integration_with_node_fixture( - hass, "window-covering", matter_client - ) - - # This tests needs to be adjusted to remove lingering tasks @pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize( + ("fixture", "entity_id"), + [ + ("window-covering_lift", "cover.mock_lift_window_covering"), + ("window-covering_pa-lift", "cover.longan_link_wncv_da01"), + ("window-covering_tilt", "cover.mock_tilt_window_covering"), + ("window-covering_pa-tilt", "cover.mock_pa_tilt_window_covering"), + ("window-covering_full", "cover.mock_full_window_covering"), + ], +) async def test_cover( hass: HomeAssistant, matter_client: MagicMock, - window_covering: MatterNode, + fixture: str, + entity_id: str, ) -> None: - """Test window covering.""" + """Test window covering commands that always are implemented.""" + + window_covering = await setup_integration_with_node_fixture( + hass, + fixture, + matter_client, + ) + await hass.services.async_call( "cover", "close_cover", { - "entity_id": "cover.longan_link_wncv_da01", + "entity_id": entity_id, }, blocking=True, ) @@ -59,7 +67,7 @@ async def test_cover( "cover", "stop_cover", { - "entity_id": "cover.longan_link_wncv_da01", + "entity_id": entity_id, }, blocking=True, ) @@ -76,7 +84,7 @@ async def test_cover( "cover", "open_cover", { - "entity_id": "cover.longan_link_wncv_da01", + "entity_id": entity_id, }, blocking=True, ) @@ -89,11 +97,36 @@ async def test_cover( ) matter_client.send_device_command.reset_mock() + +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize( + ("fixture", "entity_id"), + [ + ("window-covering_lift", "cover.mock_lift_window_covering"), + ("window-covering_pa-lift", "cover.longan_link_wncv_da01"), + ("window-covering_full", "cover.mock_full_window_covering"), + ], +) +async def test_cover_lift( + hass: HomeAssistant, + matter_client: MagicMock, + fixture: str, + entity_id: str, +) -> None: + """Test window covering devices with lift and position aware lift features.""" + + window_covering = await setup_integration_with_node_fixture( + hass, + fixture, + matter_client, + ) + await hass.services.async_call( "cover", "set_cover_position", { - "entity_id": "cover.longan_link_wncv_da01", + "entity_id": entity_id, "position": 50, }, blocking=True, @@ -107,35 +140,300 @@ async def test_cover( ) matter_client.send_device_command.reset_mock() - set_node_attribute(window_covering, 1, 258, 8, 30) - set_node_attribute(window_covering, 1, 258, 10, 2) + set_node_attribute(window_covering, 1, 258, 10, 0b001010) await trigger_subscription_callback(hass, matter_client) - state = hass.states.get("cover.longan_link_wncv_da01") + state = hass.states.get(entity_id) assert state assert state.state == STATE_CLOSING - set_node_attribute(window_covering, 1, 258, 8, 0) - set_node_attribute(window_covering, 1, 258, 10, 0) + set_node_attribute(window_covering, 1, 258, 10, 0b000101) await trigger_subscription_callback(hass, matter_client) - state = hass.states.get("cover.longan_link_wncv_da01") - assert state - assert state.state == STATE_OPEN - - set_node_attribute(window_covering, 1, 258, 8, 50) - set_node_attribute(window_covering, 1, 258, 10, 1) - await trigger_subscription_callback(hass, matter_client) - - state = hass.states.get("cover.longan_link_wncv_da01") + state = hass.states.get(entity_id) assert state assert state.state == STATE_OPENING - set_node_attribute(window_covering, 1, 258, 8, 100) - set_node_attribute(window_covering, 1, 258, 10, 0) + +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize( + ("fixture", "entity_id"), + [ + ("window-covering_lift", "cover.mock_lift_window_covering"), + ], +) +async def test_cover_lift_only( + hass: HomeAssistant, + matter_client: MagicMock, + fixture: str, + entity_id: str, +) -> None: + """Test window covering devices with lift feature and without position aware lift feature.""" + + window_covering = await setup_integration_with_node_fixture( + hass, + fixture, + matter_client, + ) + + set_node_attribute(window_covering, 1, 258, 65529, [0, 1, 2]) await trigger_subscription_callback(hass, matter_client) - state = hass.states.get("cover.longan_link_wncv_da01") + state = hass.states.get(entity_id) + assert state + assert state.attributes["supported_features"] & CoverEntityFeature.SET_POSITION == 0 + + set_node_attribute(window_covering, 1, 258, 65529, [0, 1, 2, 5]) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get(entity_id) + assert state + assert state.attributes["supported_features"] & CoverEntityFeature.SET_POSITION != 0 + + +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize( + ("fixture", "entity_id"), + [ + ("window-covering_pa-lift", "cover.longan_link_wncv_da01"), + ], +) +async def test_cover_position_aware_lift( + hass: HomeAssistant, + matter_client: MagicMock, + fixture: str, + entity_id: str, +) -> None: + """Test window covering devices with position aware lift features.""" + + window_covering = await setup_integration_with_node_fixture( + hass, + fixture, + matter_client, + ) + + state = hass.states.get(entity_id) + assert state + mask = ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.STOP + | CoverEntityFeature.SET_POSITION + ) + assert state.attributes["supported_features"] & mask == mask + + for position in (0, 99): + set_node_attribute(window_covering, 1, 258, 8, position) + set_node_attribute(window_covering, 1, 258, 10, 0b000000) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get(entity_id) + assert state + assert state.attributes["current_position"] == 100 - position + assert state.state == STATE_OPEN + + set_node_attribute(window_covering, 1, 258, 8, 100) + set_node_attribute(window_covering, 1, 258, 10, 0b000000) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get(entity_id) assert state assert state.attributes["current_position"] == 0 assert state.state == STATE_CLOSED + + +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize( + ("fixture", "entity_id"), + [ + ("window-covering_tilt", "cover.mock_tilt_window_covering"), + ("window-covering_pa-tilt", "cover.mock_pa_tilt_window_covering"), + ("window-covering_full", "cover.mock_full_window_covering"), + ], +) +async def test_cover_tilt( + hass: HomeAssistant, + matter_client: MagicMock, + fixture: str, + entity_id: str, +) -> None: + """Test window covering devices with tilt and position aware tilt features.""" + + window_covering = await setup_integration_with_node_fixture( + hass, + fixture, + matter_client, + ) + + await hass.services.async_call( + "cover", + "set_cover_tilt_position", + { + "entity_id": entity_id, + "tilt_position": 50, + }, + blocking=True, + ) + + assert matter_client.send_device_command.call_count == 1 + assert matter_client.send_device_command.call_args == call( + node_id=window_covering.node_id, + endpoint_id=1, + command=clusters.WindowCovering.Commands.GoToTiltPercentage(5000), + ) + matter_client.send_device_command.reset_mock() + + await trigger_subscription_callback(hass, matter_client) + + set_node_attribute(window_covering, 1, 258, 10, 0b100010) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_CLOSING + + set_node_attribute(window_covering, 1, 258, 10, 0b010001) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OPENING + + +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize( + ("fixture", "entity_id"), + [ + ("window-covering_tilt", "cover.mock_tilt_window_covering"), + ], +) +async def test_cover_tilt_only( + hass: HomeAssistant, + matter_client: MagicMock, + fixture: str, + entity_id: str, +) -> None: + """Test window covering devices with tilt feature and without position aware tilt feature.""" + + window_covering = await setup_integration_with_node_fixture( + hass, + fixture, + matter_client, + ) + + set_node_attribute(window_covering, 1, 258, 65529, [0, 1, 2]) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get(entity_id) + assert state + assert ( + state.attributes["supported_features"] & CoverEntityFeature.SET_TILT_POSITION + == 0 + ) + + set_node_attribute(window_covering, 1, 258, 65529, [0, 1, 2, 8]) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get(entity_id) + assert state + assert ( + state.attributes["supported_features"] & CoverEntityFeature.SET_TILT_POSITION + != 0 + ) + + +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize( + ("fixture", "entity_id"), + [ + ("window-covering_pa-tilt", "cover.mock_pa_tilt_window_covering"), + ], +) +async def test_cover_position_aware_tilt( + hass: HomeAssistant, + matter_client: MagicMock, + fixture: str, + entity_id: str, +) -> None: + """Test window covering devices with position aware tilt feature.""" + + window_covering = await setup_integration_with_node_fixture( + hass, + fixture, + matter_client, + ) + + state = hass.states.get(entity_id) + assert state + mask = ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.STOP + | CoverEntityFeature.SET_TILT_POSITION + ) + assert state.attributes["supported_features"] & mask == mask + + for tilt_position in (0, 99, 100): + set_node_attribute(window_covering, 1, 258, 9, tilt_position) + set_node_attribute(window_covering, 1, 258, 10, 0b000000) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get(entity_id) + assert state + assert state.attributes["current_tilt_position"] == 100 - tilt_position + + +async def test_cover_full_features( + hass: HomeAssistant, + matter_client: MagicMock, +) -> None: + """Test window covering devices with all the features.""" + + window_covering = await setup_integration_with_node_fixture( + hass, + "window-covering_full", + matter_client, + ) + entity_id = "cover.mock_full_window_covering" + + state = hass.states.get(entity_id) + assert state + mask = ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.STOP + | CoverEntityFeature.SET_POSITION + | CoverEntityFeature.SET_TILT_POSITION + ) + assert state.attributes["supported_features"] & mask == mask + + set_node_attribute(window_covering, 1, 258, 8, 100) + set_node_attribute(window_covering, 1, 258, 9, 100) + set_node_attribute(window_covering, 1, 258, 10, 0b000000) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_CLOSED + + set_node_attribute(window_covering, 1, 258, 8, 50) + set_node_attribute(window_covering, 1, 258, 9, 100) + set_node_attribute(window_covering, 1, 258, 10, 0b000000) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OPEN + + set_node_attribute(window_covering, 1, 258, 8, 100) + set_node_attribute(window_covering, 1, 258, 9, 50) + set_node_attribute(window_covering, 1, 258, 10, 0b000000) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_CLOSED From 24e1243da70c390618cd5fb360c729245b6dd16e Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 30 May 2023 14:39:19 +0200 Subject: [PATCH 683/822] Delay release of HA Python release until Docker is ready (#93798) --- .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 fe0b2b5c8904..ae0cfb518632 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -62,7 +62,7 @@ jobs: build_python: name: Build PyPi package - needs: init + needs: ["init", "build_base"] runs-on: ubuntu-latest if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true' steps: From d05d67414af1202adf62e5175f9f206fdeb818fd Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 30 May 2023 15:04:35 +0200 Subject: [PATCH 684/822] Teach search about blueprints (#78535) --- .../components/automation/__init__.py | 14 +++ homeassistant/components/script/__init__.py | 14 +++ homeassistant/components/search/__init__.py | 33 +++++- tests/components/search/test_init.py | 107 ++++++++++++++++++ 4 files changed, 167 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 277952c5b2ec..600cc6013e49 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -228,6 +228,20 @@ def automations_with_blueprint(hass: HomeAssistant, blueprint_path: str) -> list ] +@callback +def blueprint_in_automation(hass: HomeAssistant, entity_id: str) -> str | None: + """Return the blueprint the automation is based on or None.""" + if DOMAIN not in hass.data: + return None + + component: EntityComponent[AutomationEntity] = hass.data[DOMAIN] + + if (automation_entity := component.get_entity(entity_id)) is None: + return None + + return automation_entity.referenced_blueprint + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up all automations.""" hass.data[DOMAIN] = component = EntityComponent[AutomationEntity]( diff --git a/homeassistant/components/script/__init__.py b/homeassistant/components/script/__init__.py index 9c4137c1beac..659131e902bb 100644 --- a/homeassistant/components/script/__init__.py +++ b/homeassistant/components/script/__init__.py @@ -160,6 +160,20 @@ def scripts_with_blueprint(hass: HomeAssistant, blueprint_path: str) -> list[str ] +@callback +def blueprint_in_script(hass: HomeAssistant, entity_id: str) -> str | None: + """Return the blueprint the script is based on or None.""" + if DOMAIN not in hass.data: + return None + + component: EntityComponent[ScriptEntity] = hass.data[DOMAIN] + + if (script_entity := component.get_entity(entity_id)) is None: + return None + + return script_entity.referenced_blueprint + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Load the scripts from the configuration.""" hass.data[DOMAIN] = component = EntityComponent[ScriptEntity](LOGGER, DOMAIN, hass) diff --git a/homeassistant/components/search/__init__.py b/homeassistant/components/search/__init__.py index a603d12e8c4f..69796800e615 100644 --- a/homeassistant/components/search/__init__.py +++ b/homeassistant/components/search/__init__.py @@ -37,7 +37,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ( "area", "automation", - "blueprint", + "automation_blueprint", "config_entry", "device", "entity", @@ -45,6 +45,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: "person", "scene", "script", + "script_blueprint", ) ), vol.Required("item_id"): str, @@ -81,10 +82,12 @@ class Searcher: DONT_RESOLVE = { "area", "automation", + "automation_blueprint", "config_entry", "group", "scene", "script", + "script_blueprint", } # These types exist as an entity and so need cleanup in results EXIST_AS_ENTITY = {"automation", "group", "person", "scene", "script"} @@ -176,6 +179,22 @@ class Searcher: for area in automation.areas_in_automation(self.hass, automation_entity_id): self._add_or_resolve("area", area) + if blueprint := automation.blueprint_in_automation( + self.hass, automation_entity_id + ): + self._add_or_resolve("automation_blueprint", blueprint) + + @callback + def _resolve_automation_blueprint(self, blueprint_path) -> None: + """Resolve an automation blueprint. + + Will only be called if blueprint is an entry point. + """ + for entity_id in automation.automations_with_blueprint( + self.hass, blueprint_path + ): + self._add_or_resolve("automation", entity_id) + @callback def _resolve_config_entry(self, config_entry_id) -> None: """Resolve a config entry. @@ -295,3 +314,15 @@ class Searcher: for area in script.areas_in_script(self.hass, script_entity_id): self._add_or_resolve("area", area) + + if blueprint := script.blueprint_in_script(self.hass, script_entity_id): + self._add_or_resolve("script_blueprint", blueprint) + + @callback + def _resolve_script_blueprint(self, blueprint_path) -> None: + """Resolve a script blueprint. + + Will only be called if blueprint is an entry point. + """ + for entity_id in script.scripts_with_blueprint(self.hass, blueprint_path): + self._add_or_resolve("script", entity_id) diff --git a/tests/components/search/test_init.py b/tests/components/search/test_init.py index 4ee6f46c2f86..40ec9c22afe6 100644 --- a/tests/components/search/test_init.py +++ b/tests/components/search/test_init.py @@ -449,6 +449,113 @@ async def test_person_lookup(hass: HomeAssistant) -> None: } +async def test_automation_blueprint(hass): + """Test searching for automation blueprints.""" + + assert await async_setup_component( + hass, + "automation", + { + "automation": [ + { + "alias": "blueprint_automation_1", + "trigger": {"platform": "template", "value_template": "true"}, + "use_blueprint": { + "path": "test_event_service.yaml", + "input": { + "trigger_event": "blueprint_event_1", + "service_to_call": "test.automation_1", + "a_number": 5, + }, + }, + }, + { + "alias": "blueprint_automation_2", + "trigger": {"platform": "template", "value_template": "true"}, + "use_blueprint": { + "path": "test_event_service.yaml", + "input": { + "trigger_event": "blueprint_event_2", + "service_to_call": "test.automation_2", + "a_number": 5, + }, + }, + }, + ] + }, + ) + + # Ensure automations set up correctly. + assert hass.states.get("automation.blueprint_automation_1") is not None + assert hass.states.get("automation.blueprint_automation_1") is not None + + device_reg = dr.async_get(hass) + entity_reg = er.async_get(hass) + + searcher = search.Searcher(hass, device_reg, entity_reg, MOCK_ENTITY_SOURCES) + assert searcher.async_search("automation", "automation.blueprint_automation_1") == { + "automation": {"automation.blueprint_automation_2"}, + "automation_blueprint": {"test_event_service.yaml"}, + "entity": {"light.kitchen"}, + } + + searcher = search.Searcher(hass, device_reg, entity_reg, MOCK_ENTITY_SOURCES) + assert searcher.async_search("automation_blueprint", "test_event_service.yaml") == { + "automation": { + "automation.blueprint_automation_1", + "automation.blueprint_automation_2", + }, + } + + +async def test_script_blueprint(hass): + """Test searching for script blueprints.""" + + assert await async_setup_component( + hass, + "script", + { + "script": { + "blueprint_script_1": { + "use_blueprint": { + "path": "test_service.yaml", + "input": { + "service_to_call": "test.automation", + }, + } + }, + "blueprint_script_2": { + "use_blueprint": { + "path": "test_service.yaml", + "input": { + "service_to_call": "test.automation", + }, + } + }, + } + }, + ) + + # Ensure automations set up correctly. + assert hass.states.get("script.blueprint_script_1") is not None + assert hass.states.get("script.blueprint_script_1") is not None + + device_reg = dr.async_get(hass) + entity_reg = er.async_get(hass) + + searcher = search.Searcher(hass, device_reg, entity_reg, MOCK_ENTITY_SOURCES) + assert searcher.async_search("script", "script.blueprint_script_1") == { + "entity": {"light.kitchen"}, + "script": {"script.blueprint_script_2"}, + "script_blueprint": {"test_service.yaml"}, + } + + searcher = search.Searcher(hass, device_reg, entity_reg, MOCK_ENTITY_SOURCES) + assert searcher.async_search("script_blueprint", "test_service.yaml") == { + "script": {"script.blueprint_script_1", "script.blueprint_script_2"}, + } + + async def test_ws_api(hass: HomeAssistant, hass_ws_client: WebSocketGenerator) -> None: """Test WS API.""" assert await async_setup_component(hass, "search", {}) From 11299c453706e742361e379a5ebe214a3324f7ce Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 30 May 2023 16:16:56 +0200 Subject: [PATCH 685/822] Deduplicate Thread datasets based on EXTPANID (#93795) * Deduplicate Thread datasets based on EXTPANID * Apply suggestions from code review --- .../components/thread/dataset_store.py | 149 +++++++++- tests/components/otbr/__init__.py | 2 +- tests/components/thread/__init__.py | 4 +- tests/components/thread/test_dataset_store.py | 268 +++++++++++++++++- tests/components/thread/test_websocket_api.py | 4 +- 5 files changed, 410 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/thread/dataset_store.py b/homeassistant/components/thread/dataset_store.py index a277ceb16a24..643981e763b7 100644 --- a/homeassistant/components/thread/dataset_store.py +++ b/homeassistant/components/thread/dataset_store.py @@ -4,6 +4,7 @@ from __future__ import annotations import dataclasses from datetime import datetime from functools import cached_property +import logging from typing import Any, cast from python_otbr_api import tlv_parser @@ -18,9 +19,11 @@ from homeassistant.util import dt as dt_util, ulid as ulid_util DATA_STORE = "thread.datasets" STORAGE_KEY = "thread.datasets" STORAGE_VERSION_MAJOR = 1 -STORAGE_VERSION_MINOR = 1 +STORAGE_VERSION_MINOR = 2 SAVE_DELAY = 10 +_LOGGER = logging.getLogger(__name__) + class DatasetPreferredError(HomeAssistantError): """Raised when attempting to delete the preferred dataset.""" @@ -49,11 +52,9 @@ class DatasetEntry: return tlv_parser.parse_tlv(self.tlv) @property - def extended_pan_id(self) -> str | None: + def extended_pan_id(self) -> str: """Return extended PAN ID as a hex string.""" - if (ext_pan_id := self.dataset.get(MeshcopTLVType.EXTPANID)) is None: - return None - return str(ext_pan_id) + return str(self.dataset[MeshcopTLVType.EXTPANID]) @property def network_name(self) -> str | None: @@ -77,6 +78,88 @@ class DatasetEntry: } +class DatasetStoreStore(Store): + """Store Thread datasets.""" + + async def _async_migrate_func( + self, old_major_version: int, old_minor_version: int, old_data: dict[str, Any] + ) -> dict[str, Any]: + """Migrate to the new version.""" + if old_major_version == 1: + if old_minor_version < 2: + datasets: dict[str, DatasetEntry] = {} + preferred_dataset = old_data["preferred_dataset"] + + for dataset in old_data["datasets"]: + created = cast(datetime, dt_util.parse_datetime(dataset["created"])) + entry = DatasetEntry( + created=created, + id=dataset["id"], + source=dataset["source"], + tlv=dataset["tlv"], + ) + if ( + MeshcopTLVType.EXTPANID not in entry.dataset + or MeshcopTLVType.ACTIVETIMESTAMP not in entry.dataset + ): + _LOGGER.warning( + "Dropped invalid Thread dataset '%s'", entry.tlv + ) + if entry.id == preferred_dataset: + preferred_dataset = None + continue + + if entry.extended_pan_id in datasets: + if datasets[entry.extended_pan_id].id == preferred_dataset: + _LOGGER.warning( + ( + "Dropped duplicated Thread dataset '%s' " + "(duplicate of preferred dataset '%s')" + ), + entry.tlv, + datasets[entry.extended_pan_id].tlv, + ) + continue + new_timestamp = cast( + tlv_parser.Timestamp, + entry.dataset[MeshcopTLVType.ACTIVETIMESTAMP], + ) + old_timestamp = cast( + tlv_parser.Timestamp, + datasets[entry.extended_pan_id].dataset[ + MeshcopTLVType.ACTIVETIMESTAMP + ], + ) + if old_timestamp.seconds >= new_timestamp.seconds or ( + old_timestamp.seconds == new_timestamp.seconds + and old_timestamp.ticks >= new_timestamp.ticks + ): + _LOGGER.warning( + ( + "Dropped duplicated Thread dataset '%s' " + "(duplicate of '%s')" + ), + entry.tlv, + datasets[entry.extended_pan_id].tlv, + ) + continue + _LOGGER.warning( + ( + "Dropped duplicated Thread dataset '%s' " + "(duplicate of '%s')" + ), + datasets[entry.extended_pan_id].tlv, + entry.tlv, + ) + datasets[entry.extended_pan_id] = entry + data = { + "preferred_dataset": preferred_dataset, + "datasets": [dataset.to_json() for dataset in datasets.values()], + } + + return data + + class DatasetStore: """Class to hold a collection of thread datasets.""" @@ -85,7 +168,7 @@ class DatasetStore: self.hass = hass self.datasets: dict[str, DatasetEntry] = {} self._preferred_dataset: str | None = None - self._store: Store[dict[str, Any]] = Store( + self._store: Store[dict[str, Any]] = DatasetStoreStore( hass, STORAGE_VERSION_MAJOR, STORAGE_KEY, @@ -98,10 +181,64 @@ class DatasetStore: """Add dataset, does nothing if it already exists.""" # Make sure the tlv is valid dataset = tlv_parser.parse_tlv(tlv) + + # Don't allow adding a dataset which does not have an extended pan id or + # timestamp + if ( + MeshcopTLVType.EXTPANID not in dataset + or MeshcopTLVType.ACTIVETIMESTAMP not in dataset + ): + raise HomeAssistantError("Invalid dataset") + # Bail out if the dataset already exists if any(entry for entry in self.datasets.values() if entry.dataset == dataset): return + # Update if dataset with same extended pan id exists and the timestamp + # is newer + if entry := next( + ( + entry + for entry in self.datasets.values() + if entry.dataset[MeshcopTLVType.EXTPANID] + == dataset[MeshcopTLVType.EXTPANID] + ), + None, + ): + new_timestamp = cast( + tlv_parser.Timestamp, dataset[MeshcopTLVType.ACTIVETIMESTAMP] + ) + old_timestamp = cast( + tlv_parser.Timestamp, + entry.dataset[MeshcopTLVType.ACTIVETIMESTAMP], + ) + if old_timestamp.seconds >= new_timestamp.seconds or ( + old_timestamp.seconds == new_timestamp.seconds + and old_timestamp.ticks >= new_timestamp.ticks + ): + _LOGGER.warning( + ( + "Got dataset with same extended PAN ID and same or older active" + " timestamp, old dataset: '%s', new dataset: '%s'" + ), + entry.tlv, + tlv, + ) + return + _LOGGER.debug( + ( + "Updating dataset with same extended PAN ID and newer active " + "timestamp, old dataset: '%s', new dataset: '%s'" + ), + entry.tlv, + tlv, + ) + self.datasets[entry.id] = dataclasses.replace( + self.datasets[entry.id], tlv=tlv + ) + self.async_schedule_save() + return + entry = DatasetEntry(source=source, tlv=tlv) self.datasets[entry.id] = entry # Set to preferred if there is no preferred dataset diff --git a/tests/components/otbr/__init__.py b/tests/components/otbr/__init__.py index d6b2a406aa1b..1f103884db28 100644 --- a/tests/components/otbr/__init__.py +++ b/tests/components/otbr/__init__.py @@ -3,7 +3,7 @@ BASE_URL = "http://core-silabs-multiprotocol:8081" CONFIG_ENTRY_DATA = {"url": "http://core-silabs-multiprotocol:8081"} DATASET_CH15 = bytes.fromhex( - "0E080000000000010000000300000F35060004001FFFE00208F642646DA209B1C00708FDF57B5A" + "0E080000000000010000000300000F35060004001FFFE00208F642646DA209B1D00708FDF57B5A" "0FE2AAF60510DE98B5BA1A528FEE049D4B4B01835375030D4F70656E5468726561642048410102" "25A40410F5DD18371BFD29E1A601EF6FFAD94C030C0402A0F7F8" ) diff --git a/tests/components/thread/__init__.py b/tests/components/thread/__init__.py index fd3cc3d9d859..e7435b8e94a2 100644 --- a/tests/components/thread/__init__.py +++ b/tests/components/thread/__init__.py @@ -7,13 +7,13 @@ DATASET_1 = ( ) DATASET_2 = ( - "0E080000000000010000000300000F35060004001FFFE0020811111111222222220708FDAD70BF" + "0E080000000000010000000300000F35060004001FFFE0020811111111222222330708FDAD70BF" "E5AA15DD051000112233445566778899AABBCCDDEEFF030E486f6d65417373697374616e742101" "0212340410445F2B5CA6F2A93A55CE570A70EFEECB0C0402A0F7F8" ) DATASET_3 = ( - "0E080000000000010000000300000F35060004001FFFE0020811111111222222220708FDAD70BF" + "0E080000000000010000000300000F35060004001FFFE0020811111111222222440708FDAD70BF" "E5AA15DD051000112233445566778899AABBCCDDEEFF030E7ef09f90a3f09f90a5f09f90a47e01" "0212340410445F2B5CA6F2A93A55CE570A70EFEECB0C0402A0F7F8" ) diff --git a/tests/components/thread/test_dataset_store.py b/tests/components/thread/test_dataset_store.py index 45efd232ba71..1ed754dbdcd7 100644 --- a/tests/components/thread/test_dataset_store.py +++ b/tests/components/thread/test_dataset_store.py @@ -26,7 +26,25 @@ DATASET_1_BAD_CHANNEL = ( ) DATASET_1_NO_CHANNEL = ( - "0E08000000000001000035060004001FFFE0020811111111222222220708FDAD70BF" + "0E08000000000001000035060004001FFFE0020811111111222222250708FDAD70BF" + "E5AA15DD051000112233445566778899AABBCCDDEEFF030E4F70656E54687265616444656D6F01" + "0212340410445F2B5CA6F2A93A55CE570A70EFEECB0C0402A0F7F8" +) + +DATASET_1_NO_EXTPANID = ( + "0E080000000000010000000300000F35060004001FFFE00708FDAD70BF" + "E5AA15DD051000112233445566778899AABBCCDDEEFF030E4F70656E54687265616444656D6F01" + "0212340410445F2B5CA6F2A93A55CE570A70EFEECB0C0402A0F7F8" +) + +DATASET_1_NO_ACTIVETIMESTAMP = ( + "000300000F35060004001FFFE0020811111111222222220708FDAD70BF" + "E5AA15DD051000112233445566778899AABBCCDDEEFF030E4F70656E54687265616444656D6F01" + "0212340410445F2B5CA6F2A93A55CE570A70EFEECB0C0402A0F7F8" +) + +DATASET_1_LARGER_TIMESTAMP = ( + "0E080000000000020000000300000F35060004001FFFE0020811111111222222220708FDAD70BF" "E5AA15DD051000112233445566778899AABBCCDDEEFF030E4F70656E54687265616444656D6F01" "0212340410445F2B5CA6F2A93A55CE570A70EFEECB0C0402A0F7F8" ) @@ -148,14 +166,14 @@ async def test_dataset_properties(hass: HomeAssistant) -> None: dataset = store.async_get(dataset_2.id) assert dataset == dataset_2 assert dataset.channel == 15 - assert dataset.extended_pan_id == "1111111122222222" + assert dataset.extended_pan_id == "1111111122222233" assert dataset.network_name == "HomeAssistant!" assert dataset.pan_id == "1234" dataset = store.async_get(dataset_3.id) assert dataset == dataset_3 assert dataset.channel == 15 - assert dataset.extended_pan_id == "1111111122222222" + assert dataset.extended_pan_id == "1111111122222244" assert dataset.network_name == "~🐣🐥🐤~" assert dataset.pan_id == "1234" @@ -164,6 +182,58 @@ async def test_dataset_properties(hass: HomeAssistant) -> None: assert dataset.channel is None +@pytest.mark.parametrize( + ("dataset", "error"), + [ + (DATASET_1_BAD_CHANNEL, TLVError), + (DATASET_1_NO_EXTPANID, HomeAssistantError), + (DATASET_1_NO_ACTIVETIMESTAMP, HomeAssistantError), + ], +) +async def test_add_bad_dataset(hass: HomeAssistant, dataset, error) -> None: + """Test adding a bad dataset.""" + with pytest.raises(error): + await dataset_store.async_add_dataset(hass, "test", dataset) + + +async def test_update_dataset_newer(hass: HomeAssistant, caplog) -> None: + """Test updating a dataset.""" + await dataset_store.async_add_dataset(hass, "test", DATASET_1) + await dataset_store.async_add_dataset(hass, "test", DATASET_1_LARGER_TIMESTAMP) + + store = await dataset_store.async_get_store(hass) + assert len(store.datasets) == 1 + assert list(store.datasets.values())[0].tlv == DATASET_1_LARGER_TIMESTAMP + + assert ( + "Updating dataset with same extended PAN ID and newer active timestamp" + in caplog.text + ) + assert ( + "Got dataset with same extended PAN ID and same or older active timestamp" + not in caplog.text + ) + + +async def test_update_dataset_older(hass: HomeAssistant, caplog) -> None: + """Test updating a dataset.""" + await dataset_store.async_add_dataset(hass, "test", DATASET_1_LARGER_TIMESTAMP) + await dataset_store.async_add_dataset(hass, "test", DATASET_1) + + store = await dataset_store.async_get_store(hass) + assert len(store.datasets) == 1 + assert list(store.datasets.values())[0].tlv == DATASET_1_LARGER_TIMESTAMP + + assert ( + "Updating dataset with same extended PAN ID and newer active timestamp" + not in caplog.text + ) + assert ( + "Got dataset with same extended PAN ID and same or older active timestamp" + in caplog.text + ) + + async def test_load_datasets(hass: HomeAssistant) -> None: """Make sure that we can load/save data correctly.""" @@ -234,19 +304,19 @@ async def test_loading_datasets_from_storage( "created": "2023-02-02T09:41:13.746514+00:00", "id": "id1", "source": "source_1", - "tlv": "DATASET_1", + "tlv": DATASET_1, }, { "created": "2023-02-02T09:41:13.746514+00:00", "id": "id2", "source": "source_2", - "tlv": "DATASET_2", + "tlv": DATASET_2, }, { "created": "2023-02-02T09:41:13.746514+00:00", "id": "id3", "source": "source_3", - "tlv": "DATASET_3", + "tlv": DATASET_3, }, ], "preferred_dataset": "id1", @@ -256,3 +326,189 @@ async def test_loading_datasets_from_storage( store = await dataset_store.async_get_store(hass) assert len(store.datasets) == 3 assert store.preferred_dataset == "id1" + + +async def test_migrate_drop_bad_datasets( + hass: HomeAssistant, hass_storage: dict[str, Any], caplog +) -> None: + """Test migrating the dataset store when the store has bad datasets.""" + hass_storage[dataset_store.STORAGE_KEY] = { + "version": dataset_store.STORAGE_VERSION_MAJOR, + "minor_version": 1, + "data": { + "datasets": [ + { + "created": "2023-02-02T09:41:13.746514+00:00", + "id": "id1", + "source": "source_1", + "tlv": DATASET_1, + }, + { + "created": "2023-02-02T09:41:13.746514+00:00", + "id": "id2", + "source": "source_2", + "tlv": DATASET_1_NO_EXTPANID, + }, + { + "created": "2023-02-02T09:41:13.746514+00:00", + "id": "id3", + "source": "source_3", + "tlv": DATASET_1_NO_ACTIVETIMESTAMP, + }, + ], + "preferred_dataset": "id1", + }, + } + + store = await dataset_store.async_get_store(hass) + assert len(store.datasets) == 1 + assert list(store.datasets.values())[0].tlv == DATASET_1 + assert store.preferred_dataset == "id1" + + assert f"Dropped invalid Thread dataset '{DATASET_1_NO_EXTPANID}'" in caplog.text + assert ( + f"Dropped invalid Thread dataset '{DATASET_1_NO_ACTIVETIMESTAMP}'" + in caplog.text + ) + + +async def test_migrate_drop_bad_datasets_preferred( + hass: HomeAssistant, hass_storage: dict[str, Any], caplog +) -> None: + """Test migrating the dataset store when the store has bad datasets.""" + hass_storage[dataset_store.STORAGE_KEY] = { + "version": dataset_store.STORAGE_VERSION_MAJOR, + "minor_version": 1, + "data": { + "datasets": [ + { + "created": "2023-02-02T09:41:13.746514+00:00", + "id": "id1", + "source": "source_1", + "tlv": DATASET_1, + }, + { + "created": "2023-02-02T09:41:13.746514+00:00", + "id": "id2", + "source": "source_2", + "tlv": DATASET_1_NO_EXTPANID, + }, + ], + "preferred_dataset": "id2", + }, + } + + store = await dataset_store.async_get_store(hass) + assert len(store.datasets) == 1 + assert store.preferred_dataset is None + + +async def test_migrate_drop_duplicate_datasets( + hass: HomeAssistant, hass_storage: dict[str, Any], caplog +) -> None: + """Test migrating the dataset store when the store has duplicated datasets.""" + hass_storage[dataset_store.STORAGE_KEY] = { + "version": dataset_store.STORAGE_VERSION_MAJOR, + "minor_version": 1, + "data": { + "datasets": [ + { + "created": "2023-02-02T09:41:13.746514+00:00", + "id": "id1", + "source": "source_1", + "tlv": DATASET_1, + }, + { + "created": "2023-02-02T09:41:13.746514+00:00", + "id": "id2", + "source": "source_2", + "tlv": DATASET_1_LARGER_TIMESTAMP, + }, + ], + "preferred_dataset": None, + }, + } + + store = await dataset_store.async_get_store(hass) + assert len(store.datasets) == 1 + assert list(store.datasets.values())[0].tlv == DATASET_1_LARGER_TIMESTAMP + assert store.preferred_dataset is None + + assert ( + f"Dropped duplicated Thread dataset '{DATASET_1}' " + f"(duplicate of '{DATASET_1_LARGER_TIMESTAMP}')" + ) in caplog.text + + +async def test_migrate_drop_duplicate_datasets_2( + hass: HomeAssistant, hass_storage: dict[str, Any], caplog +) -> None: + """Test migrating the dataset store when the store has duplicated datasets.""" + hass_storage[dataset_store.STORAGE_KEY] = { + "version": dataset_store.STORAGE_VERSION_MAJOR, + "minor_version": 1, + "data": { + "datasets": [ + { + "created": "2023-02-02T09:41:13.746514+00:00", + "id": "id2", + "source": "source_2", + "tlv": DATASET_1_LARGER_TIMESTAMP, + }, + { + "created": "2023-02-02T09:41:13.746514+00:00", + "id": "id1", + "source": "source_1", + "tlv": DATASET_1, + }, + ], + "preferred_dataset": None, + }, + } + + store = await dataset_store.async_get_store(hass) + assert len(store.datasets) == 1 + assert list(store.datasets.values())[0].tlv == DATASET_1_LARGER_TIMESTAMP + assert store.preferred_dataset is None + + assert ( + f"Dropped duplicated Thread dataset '{DATASET_1}' " + f"(duplicate of '{DATASET_1_LARGER_TIMESTAMP}')" + ) in caplog.text + + +async def test_migrate_drop_duplicate_datasets_preferred( + hass: HomeAssistant, hass_storage: dict[str, Any], caplog +) -> None: + """Test migrating the dataset store when the store has duplicated datasets.""" + hass_storage[dataset_store.STORAGE_KEY] = { + "version": dataset_store.STORAGE_VERSION_MAJOR, + "minor_version": 1, + "data": { + "datasets": [ + { + "created": "2023-02-02T09:41:13.746514+00:00", + "id": "id1", + "source": "source_1", + "tlv": DATASET_1, + }, + { + "created": "2023-02-02T09:41:13.746514+00:00", + "id": "id2", + "source": "source_2", + "tlv": DATASET_1_LARGER_TIMESTAMP, + }, + ], + "preferred_dataset": "id1", + }, + } + + store = await dataset_store.async_get_store(hass) + assert len(store.datasets) == 1 + assert list(store.datasets.values())[0].tlv == DATASET_1 + assert store.preferred_dataset == "id1" + + assert ( + f"Dropped duplicated Thread dataset '{DATASET_1_LARGER_TIMESTAMP}' " + f"(duplicate of preferred dataset '{DATASET_1}')" + ) in caplog.text diff --git a/tests/components/thread/test_websocket_api.py b/tests/components/thread/test_websocket_api.py index c7bdd78188d1..0db16318db1f 100644 --- a/tests/components/thread/test_websocket_api.py +++ b/tests/components/thread/test_websocket_api.py @@ -166,7 +166,7 @@ async def test_list_get_dataset( "channel": 15, "created": dataset_2.created.isoformat(), "dataset_id": dataset_2.id, - "extended_pan_id": "1111111122222222", + "extended_pan_id": "1111111122222233", "network_name": "HomeAssistant!", "pan_id": "1234", "preferred": False, @@ -176,7 +176,7 @@ async def test_list_get_dataset( "channel": 15, "created": dataset_3.created.isoformat(), "dataset_id": dataset_3.id, - "extended_pan_id": "1111111122222222", + "extended_pan_id": "1111111122222244", "network_name": "~🐣🐥🐤~", "pan_id": "1234", "preferred": False, From 6a8d18ab359dc4c99b5acc74a90e92bb69af3e4d Mon Sep 17 00:00:00 2001 From: PeteRager <76050312+PeteRager@users.noreply.github.com> Date: Tue, 30 May 2023 11:09:13 -0400 Subject: [PATCH 686/822] Sonos setup fails with unhandled exceptions on discovery messages (#90648) Co-authored-by: J. Nick Koston --- homeassistant/components/sonos/__init__.py | 70 +++-- tests/components/sonos/conftest.py | 140 +++++++-- tests/components/sonos/test_init.py | 316 ++++++++++++++++++++- 3 files changed, 471 insertions(+), 55 deletions(-) diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index ea0a16229c14..e6b328cbcb0f 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -368,7 +368,9 @@ class SonosDiscoveryManager: self, now: datetime.datetime | None = None ) -> None: """Add and maintain Sonos devices from a manual configuration.""" - for host in self.hosts: + + # Loop through each configured host and verify that Soco attributes are available for it. + for host in self.hosts.copy(): ip_addr = await self.hass.async_add_executor_job(socket.gethostbyname, host) soco = SoCo(ip_addr) try: @@ -376,7 +378,12 @@ class SonosDiscoveryManager: sync_get_visible_zones, soco, ) - except (OSError, SoCoException, Timeout) as ex: + except ( + OSError, + SoCoException, + Timeout, + asyncio.TimeoutError, + ) as ex: if not self.hosts_in_error.get(ip_addr): _LOGGER.warning( "Could not get visible Sonos devices from %s: %s", ip_addr, ex @@ -386,31 +393,30 @@ class SonosDiscoveryManager: _LOGGER.debug( "Could not get visible Sonos devices from %s: %s", ip_addr, ex ) + continue - else: - if self.hosts_in_error.pop(ip_addr, None): - _LOGGER.info("Connection restablished to Sonos device %s", ip_addr) - if new_hosts := { - x.ip_address - for x in visible_zones - if x.ip_address not in self.hosts - }: - _LOGGER.debug("Adding to manual hosts: %s", new_hosts) - self.hosts.update(new_hosts) - async_dispatcher_send( - self.hass, - f"{SONOS_SPEAKER_ACTIVITY}-{soco.uid}", - "manual zone scan", - ) - break + if self.hosts_in_error.pop(ip_addr, None): + _LOGGER.info("Connection reestablished to Sonos device %s", ip_addr) + # Each speaker has the topology for other online speakers, so add them in here if they were not + # configured. The metadata is already in Soco for these. + if new_hosts := { + x.ip_address for x in visible_zones if x.ip_address not in self.hosts + }: + _LOGGER.debug("Adding to manual hosts: %s", new_hosts) + self.hosts.update(new_hosts) - for host in self.hosts.copy(): - ip_addr = await self.hass.async_add_executor_job(socket.gethostbyname, host) if self.is_device_invisible(ip_addr): _LOGGER.debug("Discarding %s from manual hosts", ip_addr) self.hosts.discard(ip_addr) - continue + # Loop through each configured host that is not in error. Send a discovery message + # if a speaker does not already exist, or ping the speaker if it is unavailable. + for host in self.hosts.copy(): + ip_addr = await self.hass.async_add_executor_job(socket.gethostbyname, host) + soco = SoCo(ip_addr) + # Skip hosts that are in error to avoid blocking call on soco.uuid in event loop + if self.hosts_in_error.get(ip_addr): + continue known_speaker = next( ( speaker @@ -420,12 +426,28 @@ class SonosDiscoveryManager: None, ) if not known_speaker: - await self._async_handle_discovery_message( - soco.uid, ip_addr, "manual zone scan" - ) + try: + await self._async_handle_discovery_message( + soco.uid, + ip_addr, + "manual zone scan", + ) + except ( + OSError, + SoCoException, + Timeout, + asyncio.TimeoutError, + ) as ex: + _LOGGER.warning("Discovery message failed to %s : %s", ip_addr, ex) elif not known_speaker.available: try: await self.hass.async_add_executor_job(known_speaker.ping) + # Only send the message if the ping was successful. + async_dispatcher_send( + self.hass, + f"{SONOS_SPEAKER_ACTIVITY}-{soco.uid}", + "manual zone scan", + ) except SonosUpdateError: _LOGGER.debug( "Manual poll to %s failed, keeping unavailable", ip_addr diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index 4e01ba02edd2..730f0f5e8f33 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -13,13 +13,33 @@ from homeassistant.const import CONF_HOSTS from tests.common import MockConfigEntry, load_fixture +class SonosMockEventListener: + """Mock the event listener.""" + + def __init__(self, ip_address: str) -> None: + """Initialize the mock event listener.""" + self.address = [ip_address, "8080"] + + +class SonosMockSubscribe: + """Mock the subscription.""" + + def __init__(self, ip_address: str, *args, **kwargs) -> None: + """Initialize the mock subscriber.""" + self.event_listener = SonosMockEventListener(ip_address) + self.service = Mock() + + async def unsubscribe(self) -> None: + """Unsubscribe mock.""" + + class SonosMockService: """Mock a Sonos Service used in callbacks.""" - def __init__(self, service_type): + def __init__(self, service_type, ip_address="192.168.42.2") -> None: """Initialize the instance.""" self.service_type = service_type - self.subscribe = AsyncMock() + self.subscribe = AsyncMock(return_value=SonosMockSubscribe(ip_address)) class SonosMockEvent: @@ -84,28 +104,59 @@ def config_entry_fixture(): return MockConfigEntry(domain=DOMAIN, title="Sonos") -@pytest.fixture(name="soco") -def soco_fixture( - music_library, speaker_info, current_track_info_empty, battery_info, alarm_clock -): - """Create a mock soco SoCo fixture.""" - with patch("homeassistant.components.sonos.SoCo", autospec=True) as mock, patch( - "socket.gethostbyname", return_value="192.168.42.2" - ): - mock_soco = mock.return_value - mock_soco.ip_address = "192.168.42.2" - mock_soco.uid = "RINCON_test" +class MockSoCo(MagicMock): + """Mock the Soco Object.""" + + @property + def visible_zones(self): + """Return visible zones and allow property to be overridden by device classes.""" + return {self} + + +class SoCoMockFactory: + """Factory for creating SoCo Mocks.""" + + def __init__( + self, + music_library, + speaker_info, + current_track_info_empty, + battery_info, + alarm_clock, + ) -> None: + """Initialize the mock factory.""" + self.mock_list: dict[str, MockSoCo] = {} + self.music_library = music_library + self.speaker_info = speaker_info + self.current_track_info = current_track_info_empty + self.battery_info = battery_info + self.alarm_clock = alarm_clock + + def cache_mock( + self, mock_soco: MockSoCo, ip_address: str, name: str = "Zone A" + ) -> MockSoCo: + """Put a user created mock into the cache.""" + mock_soco.mock_add_spec(SoCo) + mock_soco.ip_address = ip_address + if ip_address != "192.168.42.2": + mock_soco.uid = f"RINCON_test_{ip_address}" + else: + mock_soco.uid = "RINCON_test" mock_soco.play_mode = "NORMAL" - mock_soco.music_library = music_library - mock_soco.get_current_track_info.return_value = current_track_info_empty + mock_soco.music_library = self.music_library + mock_soco.get_current_track_info.return_value = self.current_track_info mock_soco.music_source_from_uri = SoCo.music_source_from_uri - mock_soco.get_speaker_info.return_value = speaker_info - mock_soco.avTransport = SonosMockService("AVTransport") - mock_soco.renderingControl = SonosMockService("RenderingControl") - mock_soco.zoneGroupTopology = SonosMockService("ZoneGroupTopology") - mock_soco.contentDirectory = SonosMockService("ContentDirectory") - mock_soco.deviceProperties = SonosMockService("DeviceProperties") - mock_soco.alarmClock = alarm_clock + my_speaker_info = self.speaker_info.copy() + my_speaker_info["zone_name"] = name + my_speaker_info["uid"] = mock_soco.uid + mock_soco.get_speaker_info = Mock(return_value=my_speaker_info) + + mock_soco.avTransport = SonosMockService("AVTransport", ip_address) + mock_soco.renderingControl = SonosMockService("RenderingControl", ip_address) + mock_soco.zoneGroupTopology = SonosMockService("ZoneGroupTopology", ip_address) + mock_soco.contentDirectory = SonosMockService("ContentDirectory", ip_address) + mock_soco.deviceProperties = SonosMockService("DeviceProperties", ip_address) + mock_soco.alarmClock = self.alarm_clock mock_soco.mute = False mock_soco.night_mode = True mock_soco.dialog_level = True @@ -123,11 +174,48 @@ def soco_fixture( mock_soco.surround_level = 3 mock_soco.music_surround_level = 4 mock_soco.soundbar_audio_input_format = "Dolby 5.1" - mock_soco.get_battery_info.return_value = battery_info + mock_soco.get_battery_info.return_value = self.battery_info mock_soco.all_zones = {mock_soco} - mock_soco.visible_zones = {mock_soco} mock_soco.group.coordinator = mock_soco - yield mock_soco + self.mock_list[ip_address] = mock_soco + return mock_soco + + def get_mock(self, *args) -> SoCo: + """Return a mock.""" + if len(args) > 0: + ip_address = args[0] + else: + ip_address = "192.168.42.2" + if ip_address in self.mock_list: + return self.mock_list[ip_address] + mock_soco = MockSoCo(name=f"Soco Mock {ip_address}") + self.cache_mock(mock_soco, ip_address) + return mock_soco + + +def patch_gethostbyname(host: str) -> str: + """Mock to return host name as ip address for testing.""" + return host + + +@pytest.fixture(name="soco_factory") +def soco_factory( + music_library, speaker_info, current_track_info_empty, battery_info, alarm_clock +): + """Create factory for instantiating SoCo mocks.""" + factory = SoCoMockFactory( + music_library, speaker_info, current_track_info_empty, battery_info, alarm_clock + ) + with patch("homeassistant.components.sonos.SoCo", new=factory.get_mock), patch( + "socket.gethostbyname", side_effect=patch_gethostbyname + ), patch("homeassistant.components.sonos.ZGS_SUBSCRIPTION_TIMEOUT", 0): + yield factory + + +@pytest.fixture(name="soco") +def soco_fixture(soco_factory): + """Create a default mock soco SoCo fixture.""" + return soco_factory.get_mock() @pytest.fixture(autouse=True) @@ -172,7 +260,7 @@ def discover_fixture(soco): @pytest.fixture(name="config") def config_fixture(): """Create hass config fixture.""" - return {DOMAIN: {MP_DOMAIN: {CONF_HOSTS: ["192.168.42.1"]}}} + return {DOMAIN: {MP_DOMAIN: {CONF_HOSTS: ["192.168.42.2"]}}} @pytest.fixture(name="music_library") diff --git a/tests/components/sonos/test_init.py b/tests/components/sonos/test_init.py index 596946e9f8b3..d40720554071 100644 --- a/tests/components/sonos/test_init.py +++ b/tests/components/sonos/test_init.py @@ -1,16 +1,31 @@ """Tests for the Sonos config flow.""" +import asyncio import logging -from unittest.mock import patch +import sys +from unittest.mock import Mock, patch + +if sys.version_info[:2] < (3, 11): + from async_timeout import timeout as asyncio_timeout +else: + from asyncio import timeout as asyncio_timeout import pytest from homeassistant import config_entries, data_entry_flow from homeassistant.components import sonos, zeroconf from homeassistant.components.sonos import SonosDiscoveryManager -from homeassistant.components.sonos.const import DATA_SONOS_DISCOVERY_MANAGER -from homeassistant.core import HomeAssistant +from homeassistant.components.sonos.const import ( + DATA_SONOS_DISCOVERY_MANAGER, + SONOS_SPEAKER_ACTIVITY, +) +from homeassistant.components.sonos.exception import SonosUpdateError +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.setup import async_setup_component +from .conftest import MockSoCo, SoCoMockFactory + async def test_creating_entry_sets_up_media_player( hass: HomeAssistant, zeroconf_payload: zeroconf.ZeroconfServiceInfo @@ -84,7 +99,9 @@ async def test_async_poll_manual_hosts_warnings( manager.hosts.add("10.10.10.10") with caplog.at_level(logging.DEBUG), patch.object( manager, "_async_handle_discovery_message" - ), patch("homeassistant.components.sonos.async_call_later"), patch( + ), patch( + "homeassistant.components.sonos.async_call_later" + ) as mock_async_call_later, patch( "homeassistant.components.sonos.async_dispatcher_send" ), patch( "homeassistant.components.sonos.sync_get_visible_zones", @@ -103,6 +120,7 @@ async def test_async_poll_manual_hosts_warnings( record = caplog.records[0] assert record.levelname == "WARNING" assert "Could not get visible Sonos devices from" in record.message + assert mock_async_call_later.call_count == 1 # Second call fails again, it should be logged as a DEBUG message caplog.clear() @@ -111,6 +129,7 @@ async def test_async_poll_manual_hosts_warnings( record = caplog.records[0] assert record.levelname == "DEBUG" assert "Could not get visible Sonos devices from" in record.message + assert mock_async_call_later.call_count == 2 # Third call succeeds, it should log an info message caplog.clear() @@ -118,12 +137,14 @@ async def test_async_poll_manual_hosts_warnings( assert len(caplog.messages) == 1 record = caplog.records[0] assert record.levelname == "INFO" - assert "Connection restablished to Sonos device" in record.message + assert "Connection reestablished to Sonos device" in record.message + assert mock_async_call_later.call_count == 3 # Fourth call succeeds again, no need to log caplog.clear() await manager.async_poll_manual_hosts() assert len(caplog.messages) == 0 + assert mock_async_call_later.call_count == 4 # Fifth call fail again again, should be logged as a WARNING message caplog.clear() @@ -132,3 +153,288 @@ async def test_async_poll_manual_hosts_warnings( record = caplog.records[0] assert record.levelname == "WARNING" assert "Could not get visible Sonos devices from" in record.message + assert mock_async_call_later.call_count == 5 + + +class _MockSoCoOsError(MockSoCo): + @property + def visible_zones(self): + raise OSError() + + +class _MockSoCoVisibleZones(MockSoCo): + def set_visible_zones(self, visible_zones) -> None: + """Set visible zones.""" + self.vz_return = visible_zones # pylint: disable=attribute-defined-outside-init + + @property + def visible_zones(self): + return self.vz_return + + +async def _setup_hass(hass: HomeAssistant): + await async_setup_component( + hass, + sonos.DOMAIN, + { + "sonos": { + "media_player": { + "interface_addr": "127.0.0.1", + "hosts": ["10.10.10.1", "10.10.10.2"], + } + } + }, + ) + await hass.async_block_till_done() + + +async def test_async_poll_manual_hosts_1( + hass: HomeAssistant, + soco_factory: SoCoMockFactory, + entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Tests first device fails, second device successful, speakers do not exist.""" + soco_1 = soco_factory.cache_mock(_MockSoCoOsError(), "10.10.10.1", "Living Room") + soco_2 = soco_factory.cache_mock(MockSoCo(), "10.10.10.2", "Bedroom") + + with caplog.at_level(logging.WARNING): + await _setup_hass(hass) + assert "media_player.bedroom" in entity_registry.entities + assert "media_player.living_room" not in entity_registry.entities + assert ( + f"Could not get visible Sonos devices from {soco_1.ip_address}" + in caplog.text + ) + assert ( + f"Could not get visible Sonos devices from {soco_2.ip_address}" + not in caplog.text + ) + + +async def test_async_poll_manual_hosts_2( + hass: HomeAssistant, + soco_factory: SoCoMockFactory, + entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test first device success, second device fails, speakers do not exist.""" + soco_1 = soco_factory.cache_mock(MockSoCo(), "10.10.10.1", "Living Room") + soco_2 = soco_factory.cache_mock(_MockSoCoOsError(), "10.10.10.2", "Bedroom") + + with caplog.at_level(logging.WARNING): + await _setup_hass(hass) + assert "media_player.bedroom" not in entity_registry.entities + assert "media_player.living_room" in entity_registry.entities + assert ( + f"Could not get visible Sonos devices from {soco_1.ip_address}" + not in caplog.text + ) + assert ( + f"Could not get visible Sonos devices from {soco_2.ip_address}" + in caplog.text + ) + + +async def test_async_poll_manual_hosts_3( + hass: HomeAssistant, + soco_factory: SoCoMockFactory, + entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test both devices fail, speakers do not exist.""" + soco_1 = soco_factory.cache_mock(_MockSoCoOsError(), "10.10.10.1", "Living Room") + soco_2 = soco_factory.cache_mock(_MockSoCoOsError(), "10.10.10.2", "Bedroom") + + with caplog.at_level(logging.WARNING): + await _setup_hass(hass) + assert "media_player.bedroom" not in entity_registry.entities + assert "media_player.living_room" not in entity_registry.entities + assert ( + f"Could not get visible Sonos devices from {soco_1.ip_address}" + in caplog.text + ) + assert ( + f"Could not get visible Sonos devices from {soco_2.ip_address}" + in caplog.text + ) + + +async def test_async_poll_manual_hosts_4( + hass: HomeAssistant, + soco_factory: SoCoMockFactory, + entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test both devices are successful, speakers do not exist.""" + soco_1 = soco_factory.cache_mock(MockSoCo(), "10.10.10.1", "Living Room") + soco_2 = soco_factory.cache_mock(MockSoCo(), "10.10.10.2", "Bedroom") + + with caplog.at_level(logging.WARNING): + await _setup_hass(hass) + assert "media_player.bedroom" in entity_registry.entities + assert "media_player.living_room" in entity_registry.entities + assert ( + f"Could not get visible Sonos devices from {soco_1.ip_address}" + not in caplog.text + ) + assert ( + f"Could not get visible Sonos devices from {soco_2.ip_address}" + not in caplog.text + ) + + +class SpeakerActivity: + """Unit test class to track speaker activity messages.""" + + def __init__(self, hass: HomeAssistant, soco: MockSoCo) -> None: + """Create the object from soco.""" + self.soco = soco + self.hass = hass + self.call_count: int = 0 + self.event = asyncio.Event() + async_dispatcher_connect( + self.hass, + f"{SONOS_SPEAKER_ACTIVITY}-{self.soco.uid}", + self.speaker_activity, + ) + + @callback + def speaker_activity(self, source: str) -> None: + """Track the last activity on this speaker, set availability and resubscribe.""" + if source == "manual zone scan": + self.event.set() + self.call_count += 1 + + +async def test_async_poll_manual_hosts_5( + hass: HomeAssistant, + soco_factory: SoCoMockFactory, + entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test both succeed, speakers exist and unavailable, ping succeeds.""" + soco_1 = soco_factory.cache_mock(MockSoCo(), "10.10.10.1", "Living Room") + soco_1.renderingControl = Mock() + soco_1.renderingControl.GetVolume = Mock() + speaker_1_activity = SpeakerActivity(hass, soco_1) + soco_2 = soco_factory.cache_mock(MockSoCo(), "10.10.10.2", "Bedroom") + soco_2.renderingControl = Mock() + soco_2.renderingControl.GetVolume = Mock() + speaker_2_activity = SpeakerActivity(hass, soco_2) + with patch( + "homeassistant.components.sonos.DISCOVERY_INTERVAL" + ) as mock_discovery_interval: + # Speed up manual discovery interval so second iteration runs sooner + mock_discovery_interval.total_seconds = Mock(side_effect=[0.5, 60]) + + await _setup_hass(hass) + + assert "media_player.bedroom" in entity_registry.entities + assert "media_player.living_room" in entity_registry.entities + + with caplog.at_level(logging.DEBUG): + caplog.clear() + await speaker_1_activity.event.wait() + await speaker_2_activity.event.wait() + await hass.async_block_till_done() + assert speaker_1_activity.call_count == 1 + assert speaker_2_activity.call_count == 1 + assert "Activity on Living Room" in caplog.text + assert "Activity on Bedroom" in caplog.text + + +async def test_async_poll_manual_hosts_6( + hass: HomeAssistant, + soco_factory: SoCoMockFactory, + entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test both succeed, speakers exist and unavailable, pings fail.""" + soco_1 = soco_factory.cache_mock(MockSoCo(), "10.10.10.1", "Living Room") + # Rendering Control Get Volume is what speaker ping calls. + soco_1.renderingControl = Mock() + soco_1.renderingControl.GetVolume = Mock() + soco_1.renderingControl.GetVolume.side_effect = SonosUpdateError() + speaker_1_activity = SpeakerActivity(hass, soco_1) + soco_2 = soco_factory.cache_mock(MockSoCo(), "10.10.10.2", "Bedroom") + soco_2.renderingControl = Mock() + soco_2.renderingControl.GetVolume = Mock() + soco_2.renderingControl.GetVolume.side_effect = SonosUpdateError() + speaker_2_activity = SpeakerActivity(hass, soco_2) + + with patch( + "homeassistant.components.sonos.DISCOVERY_INTERVAL" + ) as mock_discovery_interval: + # Speed up manual discovery interval so second iteration runs sooner + mock_discovery_interval.total_seconds = Mock(side_effect=[0.5, 60]) + await _setup_hass(hass) + + assert "media_player.bedroom" in entity_registry.entities + assert "media_player.living_room" in entity_registry.entities + + with caplog.at_level(logging.DEBUG): + caplog.clear() + # The discovery events should not fire, wait with a timeout. + with pytest.raises(asyncio.TimeoutError): + async with asyncio_timeout(1.0): + await speaker_1_activity.event.wait() + await hass.async_block_till_done() + assert "Activity on Living Room" not in caplog.text + assert "Activity on Bedroom" not in caplog.text + assert speaker_1_activity.call_count == 0 + assert speaker_2_activity.call_count == 0 + + +async def test_async_poll_manual_hosts_7( + hass: HomeAssistant, + soco_factory: SoCoMockFactory, + entity_registry: er.EntityRegistry, +) -> None: + """Test both succeed, speaker do not exist, new hosts found in visible zones.""" + soco_1 = soco_factory.cache_mock( + _MockSoCoVisibleZones(), "10.10.10.1", "Living Room" + ) + soco_2 = soco_factory.cache_mock(_MockSoCoVisibleZones(), "10.10.10.2", "Bedroom") + soco_3 = soco_factory.cache_mock(MockSoCo(), "10.10.10.3", "Basement") + soco_4 = soco_factory.cache_mock(MockSoCo(), "10.10.10.4", "Garage") + soco_5 = soco_factory.cache_mock(MockSoCo(), "10.10.10.5", "Studio") + + soco_1.set_visible_zones({soco_1, soco_2, soco_3, soco_4, soco_5}) + soco_2.set_visible_zones({soco_1, soco_2, soco_3, soco_4, soco_5}) + + await _setup_hass(hass) + await hass.async_block_till_done() + + assert "media_player.bedroom" in entity_registry.entities + assert "media_player.living_room" in entity_registry.entities + assert "media_player.basement" in entity_registry.entities + assert "media_player.garage" in entity_registry.entities + assert "media_player.studio" in entity_registry.entities + + +async def test_async_poll_manual_hosts_8( + hass: HomeAssistant, + soco_factory: SoCoMockFactory, + entity_registry: er.EntityRegistry, +) -> None: + """Test both succeed, speaker do not exist, invisible zone.""" + soco_1 = soco_factory.cache_mock( + _MockSoCoVisibleZones(), "10.10.10.1", "Living Room" + ) + soco_2 = soco_factory.cache_mock(_MockSoCoVisibleZones(), "10.10.10.2", "Bedroom") + soco_3 = soco_factory.cache_mock(MockSoCo(), "10.10.10.3", "Basement") + soco_4 = soco_factory.cache_mock(MockSoCo(), "10.10.10.4", "Garage") + soco_5 = soco_factory.cache_mock(MockSoCo(), "10.10.10.5", "Studio") + + soco_1.set_visible_zones({soco_2, soco_3, soco_4, soco_5}) + soco_2.set_visible_zones({soco_2, soco_3, soco_4, soco_5}) + + await _setup_hass(hass) + await hass.async_block_till_done() + + assert "media_player.bedroom" in entity_registry.entities + assert "media_player.living_room" not in entity_registry.entities + assert "media_player.basement" in entity_registry.entities + assert "media_player.garage" in entity_registry.entities + assert "media_player.studio" in entity_registry.entities From 90bf5429cad8de3564f24d0942e7f8155808b8ab Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Tue, 30 May 2023 11:26:23 -0400 Subject: [PATCH 687/822] Add support for Window Covering CC (#93314) * Refactor zwave_js.cover and improve test coverage * Remove extra fixtures * cleanup old stuff * Get coverage to 100 * Add support for Window Covering CC * fix bug * typo * remove redundant stuff * slight change to improve readability * Add device class * Update to match specs --- homeassistant/components/zwave_js/const.py | 36 + homeassistant/components/zwave_js/cover.py | 109 +- .../components/zwave_js/discovery.py | 29 +- homeassistant/components/zwave_js/entity.py | 4 +- tests/components/zwave_js/conftest.py | 14 + .../fixtures/cover_iblinds_v3_state.json | 1236 +++++++++++++++++ tests/components/zwave_js/test_cover.py | 107 ++ 7 files changed, 1529 insertions(+), 6 deletions(-) create mode 100644 tests/components/zwave_js/fixtures/cover_iblinds_v3_state.json diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index 411d8b911b84..27d4dc003687 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -3,6 +3,10 @@ from __future__ import annotations import logging +from zwave_js_server.const.command_class.window_covering import ( + WindowCoveringPropertyKey, +) + from homeassistant.const import APPLICATION_NAME, __version__ as HA_VERSION USER_AGENT = {APPLICATION_NAME: HA_VERSION} @@ -131,3 +135,35 @@ ENTITY_DESC_KEY_TOTAL_INCREASING = "total_increasing" API_KEY_FIRMWARE_UPDATE_SERVICE = ( "2e39d98fc56386389fbb35e5a98fa1b44b9fdd8f971460303587cff408430d4cfcde6134" ) + +# Platform constants +# cover +COVER_POSITION_PROPERTY_KEYS: set[str | int | None] = { + WindowCoveringPropertyKey.INBOUND_BOTTOM, + WindowCoveringPropertyKey.INBOUND_BOTTOM_NO_POSITION, + WindowCoveringPropertyKey.INBOUND_LEFT, + WindowCoveringPropertyKey.INBOUND_LEFT_NO_POSITION, + WindowCoveringPropertyKey.INBOUND_LEFT_RIGHT, + WindowCoveringPropertyKey.INBOUND_LEFT_RIGHT_NO_POSITION, + WindowCoveringPropertyKey.INBOUND_RIGHT, + WindowCoveringPropertyKey.INBOUND_RIGHT_NO_POSITION, + WindowCoveringPropertyKey.INBOUND_TOP, + WindowCoveringPropertyKey.INBOUND_TOP_NO_POSITION, + WindowCoveringPropertyKey.INBOUND_TOP_BOTTOM, + WindowCoveringPropertyKey.INBOUND_TOP_BOTTOM_NO_POSITION, + WindowCoveringPropertyKey.OUTBOUND_BOTTOM, + WindowCoveringPropertyKey.OUTBOUND_BOTTOM_NO_POSITION, + WindowCoveringPropertyKey.OUTBOUND_LEFT, + WindowCoveringPropertyKey.OUTBOUND_LEFT_NO_POSITION, + WindowCoveringPropertyKey.OUTBOUND_RIGHT, + WindowCoveringPropertyKey.OUTBOUND_RIGHT_NO_POSITION, + WindowCoveringPropertyKey.OUTBOUND_TOP, + WindowCoveringPropertyKey.OUTBOUND_TOP_NO_POSITION, +} + +COVER_TILT_PROPERTY_KEYS: set[str | int | None] = { + WindowCoveringPropertyKey.HORIZONTAL_SLATS_ANGLE, + WindowCoveringPropertyKey.HORIZONTAL_SLATS_ANGLE_NO_POSITION, + WindowCoveringPropertyKey.VERTICAL_SLATS_ANGLE, + WindowCoveringPropertyKey.VERTICAL_SLATS_ANGLE_NO_POSITION, +} diff --git a/homeassistant/components/zwave_js/cover.py b/homeassistant/components/zwave_js/cover.py index 907d98f9c33a..1521d6e567f6 100644 --- a/homeassistant/components/zwave_js/cover.py +++ b/homeassistant/components/zwave_js/cover.py @@ -4,13 +4,23 @@ from __future__ import annotations from typing import Any, cast from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.const import TARGET_STATE_PROPERTY, TARGET_VALUE_PROPERTY +from zwave_js_server.const import ( + CURRENT_VALUE_PROPERTY, + TARGET_STATE_PROPERTY, + TARGET_VALUE_PROPERTY, +) from zwave_js_server.const.command_class.barrier_operator import BarrierState from zwave_js_server.const.command_class.multilevel_switch import ( COVER_ON_PROPERTY, COVER_OPEN_PROPERTY, COVER_UP_PROPERTY, ) +from zwave_js_server.const.command_class.window_covering import ( + NO_POSITION_PROPERTY_KEYS, + NO_POSITION_SUFFIX, + WINDOW_COVERING_OPEN_PROPERTY, + SlatStates, +) from zwave_js_server.model.driver import Driver from zwave_js_server.model.value import Value as ZwaveValue @@ -27,7 +37,12 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DATA_CLIENT, DOMAIN +from .const import ( + COVER_POSITION_PROPERTY_KEYS, + COVER_TILT_PROPERTY_KEYS, + DATA_CLIENT, + DOMAIN, +) from .discovery import ZwaveDiscoveryInfo from .discovery_data_template import CoverTiltDataTemplate from .entity import ZWaveBaseEntity @@ -49,7 +64,9 @@ async def async_setup_entry( driver = client.driver assert driver is not None # Driver is ready before platforms are loaded. entities: list[ZWaveBaseEntity] = [] - if info.platform_hint == "motorized_barrier": + if info.platform_hint == "window_covering": + entities.append(ZWaveWindowCovering(config_entry, driver, info)) + elif info.platform_hint == "motorized_barrier": entities.append(ZwaveMotorizedBarrier(config_entry, driver, info)) elif info.platform_hint and info.platform_hint.endswith("tilt"): entities.append(ZWaveTiltCover(config_entry, driver, info)) @@ -130,7 +147,7 @@ class CoverPositionMixin(ZWaveBaseEntity, CoverEntity): """Return true if cover is closed.""" if not (value := self._current_position_value) or value.value is None: return None - return bool(value.value == 0) + return bool(value.value == self._fully_closed_position) @property def current_cover_position(self) -> int | None: @@ -183,6 +200,7 @@ class CoverTiltMixin(ZWaveBaseEntity, CoverEntity): self, current_value: ZwaveValue, target_value: ZwaveValue | None = None, + stop_value: ZwaveValue | None = None, ) -> None: """Set values for tilt.""" self._attr_supported_features = ( @@ -196,6 +214,10 @@ class CoverTiltMixin(ZWaveBaseEntity, CoverEntity): TARGET_VALUE_PROPERTY, value_property_key=current_value.property_key ) + if stop_value: + self._stop_tilt_value = stop_value + self._attr_supported_features |= CoverEntityFeature.STOP_TILT + def percent_to_zwave_tilt(self, value: int) -> int: """Convert position in 0-100 scale to closed_value-open_value scale.""" return ( @@ -256,6 +278,12 @@ class CoverTiltMixin(ZWaveBaseEntity, CoverEntity): self._target_tilt_value, self._fully_closed_tilt ) + async def async_stop_cover_tilt(self, **kwargs: Any) -> None: + """Stop the cover tilt.""" + assert self._stop_tilt_value + # Stop the tilt, will stop regardless of the actual direction of travel. + await self.info.node.async_set_value(self._stop_tilt_value, False) + class ZWaveMultilevelSwitchCover(CoverPositionMixin): """Representation of a Z-Wave Cover that uses Multilevel Switch CC for position.""" @@ -304,6 +332,79 @@ class ZWaveTiltCover(ZWaveMultilevelSwitchCover, CoverTiltMixin): ) +class ZWaveWindowCovering(CoverPositionMixin, CoverTiltMixin): + """Representation of a Z-Wave Window Covering cover device.""" + + def __init__( + self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + ) -> None: + """Initialize.""" + super().__init__(config_entry, driver, info) + pos_value: ZwaveValue | None = None + tilt_value: ZwaveValue | None = None + + # If primary value is for position, we have to search for a tilt value + if info.primary_value.property_key in COVER_POSITION_PROPERTY_KEYS: + pos_value = info.primary_value + tilt_value = next( + ( + value + for property_key in COVER_TILT_PROPERTY_KEYS + if ( + value := self.get_zwave_value( + CURRENT_VALUE_PROPERTY, value_property_key=property_key + ) + ) + ), + None, + ) + # If primary value is for tilt, there is no position value + else: + tilt_value = info.primary_value + + # Set position and tilt values if they exist. If the corresponding value is of + # the type No Position, we remove the corresponding set position feature. + for set_values_func, value, set_position_feature in ( + (self._set_position_values, pos_value, CoverEntityFeature.SET_POSITION), + (self._set_tilt_values, tilt_value, CoverEntityFeature.SET_TILT_POSITION), + ): + if value: + set_values_func( + value, + stop_value=self.get_zwave_value( + WINDOW_COVERING_OPEN_PROPERTY, + value_property_key=value.property_key, + ), + ) + if value.property_key in NO_POSITION_PROPERTY_KEYS: + assert self._attr_supported_features + self._attr_supported_features ^= set_position_feature + + additional_info: list[str] = [] + for value in (self._current_position_value, self._current_tilt_value): + if value and value.property_key_name: + additional_info.append( + value.property_key_name.removesuffix(f" {NO_POSITION_SUFFIX}") + ) + self._attr_name = self.generate_name(additional_info=additional_info) + self._attr_device_class = CoverDeviceClass.WINDOW + + @property + def _fully_open_tilt(self) -> int: + """Return position to open cover tilt.""" + return SlatStates.OPEN + + @property + def _fully_closed_tilt(self) -> int: + """Return position to close cover tilt.""" + return SlatStates.CLOSED_1 + + @property + def _tilt_range(self) -> int: + """Return range of valid tilt positions.""" + return abs(SlatStates.CLOSED_2 - SlatStates.CLOSED_1) + + class ZwaveMotorizedBarrier(ZWaveBaseEntity, CoverEntity): """Representation of a Z-Wave motorized barrier device.""" diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 7c49ee7f61b0..2bd7210b0431 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -48,7 +48,7 @@ from homeassistant.const import EntityCategory, Platform from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceEntry -from .const import LOGGER +from .const import COVER_POSITION_PROPERTY_KEYS, COVER_TILT_PROPERTY_KEYS, LOGGER from .discovery_data_template import ( BaseDiscoverySchemaDataTemplate, ConfigurableFanValueMappingDataTemplate, @@ -259,6 +259,18 @@ SIREN_TONE_SCHEMA = ZWaveValueDiscoverySchema( type={ValueType.NUMBER}, ) +WINDOW_COVERING_COVER_CURRENT_VALUE_SCHEMA = ZWaveValueDiscoverySchema( + command_class={CommandClass.WINDOW_COVERING}, + property={CURRENT_VALUE_PROPERTY}, + property_key=COVER_POSITION_PROPERTY_KEYS, +) + +WINDOW_COVERING_SLAT_CURRENT_VALUE_SCHEMA = ZWaveValueDiscoverySchema( + command_class={CommandClass.WINDOW_COVERING}, + property={CURRENT_VALUE_PROPERTY}, + property_key=COVER_TILT_PROPERTY_KEYS, +) + # For device class mapping see: # https://github.com/zwave-js/node-zwave-js/blob/master/packages/config/config/deviceClasses.json DISCOVERY_SCHEMAS = [ @@ -858,6 +870,17 @@ DISCOVERY_SCHEMAS = [ ), # cover # window coverings + ZWaveDiscoverySchema( + platform=Platform.COVER, + hint="window_covering", + primary_value=WINDOW_COVERING_COVER_CURRENT_VALUE_SCHEMA, + ), + ZWaveDiscoverySchema( + platform=Platform.COVER, + hint="window_covering", + primary_value=WINDOW_COVERING_SLAT_CURRENT_VALUE_SCHEMA, + absent_values=[WINDOW_COVERING_COVER_CURRENT_VALUE_SCHEMA], + ), ZWaveDiscoverySchema( platform=Platform.COVER, hint="multilevel_switch", @@ -869,6 +892,10 @@ DISCOVERY_SCHEMAS = [ "Multiposition Motor", }, primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, + absent_values=[ + WINDOW_COVERING_COVER_CURRENT_VALUE_SCHEMA, + WINDOW_COVERING_SLAT_CURRENT_VALUE_SCHEMA, + ], ), # cover # motorized barriers diff --git a/homeassistant/components/zwave_js/entity.py b/homeassistant/components/zwave_js/entity.py index 6fe1a94b896e..de6adda121af 100644 --- a/homeassistant/components/zwave_js/entity.py +++ b/homeassistant/components/zwave_js/entity.py @@ -1,6 +1,8 @@ """Generic Z-Wave Entity Class.""" from __future__ import annotations +from collections.abc import Sequence + from zwave_js_server.const import NodeStatus from zwave_js_server.model.driver import Driver from zwave_js_server.model.value import Value as ZwaveValue, get_value_id_str @@ -139,7 +141,7 @@ class ZWaveBaseEntity(Entity): self, include_value_name: bool = False, alternate_value_name: str | None = None, - additional_info: list[str | None] | None = None, + additional_info: Sequence[str | None] | None = None, name_prefix: str | None = None, ) -> str: """Generate entity name.""" diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index 5b2417f258b5..3191bbfcea50 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -445,6 +445,12 @@ def iblinds_v2_state_fixture(): return json.loads(load_fixture("zwave_js/cover_iblinds_v2_state.json")) +@pytest.fixture(name="iblinds_v3_state", scope="session") +def iblinds_v3_state_fixture(): + """Load the iBlinds v3 node state fixture data.""" + return json.loads(load_fixture("zwave_js/cover_iblinds_v3_state.json")) + + @pytest.fixture(name="qubino_shutter_state", scope="session") def qubino_shutter_state_fixture(): """Load the Qubino Shutter node state fixture data.""" @@ -953,6 +959,14 @@ def iblinds_v2_cover_fixture(client, iblinds_v2_state): return node +@pytest.fixture(name="iblinds_v3") +def iblinds_v3_cover_fixture(client, iblinds_v3_state): + """Mock an iBlinds v3 window cover node.""" + node = Node(client, copy.deepcopy(iblinds_v3_state)) + client.driver.controller.nodes[node.node_id] = node + return node + + @pytest.fixture(name="qubino_shutter") def qubino_shutter_cover_fixture(client, qubino_shutter_state): """Mock a Qubino flush shutter node.""" diff --git a/tests/components/zwave_js/fixtures/cover_iblinds_v3_state.json b/tests/components/zwave_js/fixtures/cover_iblinds_v3_state.json new file mode 100644 index 000000000000..f0da41e4b6ff --- /dev/null +++ b/tests/components/zwave_js/fixtures/cover_iblinds_v3_state.json @@ -0,0 +1,1236 @@ +{ + "nodeId": 12, + "index": 0, + "installerIcon": 6656, + "userIcon": 6656, + "status": 4, + "ready": true, + "isListening": false, + "isRouting": true, + "isSecure": true, + "manufacturerId": 647, + "productId": 114, + "productType": 4, + "firmwareVersion": "3.12.1", + "zwavePlusVersion": 2, + "deviceConfig": { + "filename": "/data/db/devices/0x0287/iblindsv3.json", + "isEmbedded": true, + "manufacturer": "HAB Home Intelligence LLC", + "manufacturerId": 647, + "label": "iblinds V3", + "description": "Window Blind Controller", + "devices": [ + { + "productType": 4, + "productId": 113 + }, + { + "productType": 4, + "productId": 114 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "preferred": false, + "associations": {}, + "paramInformation": { + "_map": {} + } + }, + "label": "iblinds V3", + "interviewAttempts": 1, + "endpoints": [ + { + "nodeId": 12, + "index": 0, + "installerIcon": 6656, + "userIcon": 6656, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 7, + "label": "Motor Control Class C" + }, + "mandatorySupportedCCs": [32, 38, 37, 114, 134], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 38, + "name": "Multilevel Switch", + "version": 4, + "isSecure": true + }, + { + "id": 37, + "name": "Binary Switch", + "version": 2, + "isSecure": true + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 2, + "isSecure": true + }, + { + "id": 134, + "name": "Version", + "version": 3, + "isSecure": true + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": true + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": true + }, + { + "id": 85, + "name": "Transport Service", + "version": 2, + "isSecure": false + }, + { + "id": 90, + "name": "Device Reset Locally", + "version": 1, + "isSecure": true + }, + { + "id": 115, + "name": "Powerlevel", + "version": 1, + "isSecure": true + }, + { + "id": 159, + "name": "Security 2", + "version": 1, + "isSecure": true + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 122, + "name": "Firmware Update Meta Data", + "version": 5, + "isSecure": true + }, + { + "id": 128, + "name": "Battery", + "version": 1, + "isSecure": true + }, + { + "id": 112, + "name": "Configuration", + "version": 4, + "isSecure": true + }, + { + "id": 135, + "name": "Indicator", + "version": 3, + "isSecure": true + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": true + }, + { + "id": 106, + "name": "Window Covering", + "version": 1, + "isSecure": true + }, + { + "id": 152, + "name": "Security", + "version": 1, + "isSecure": true + } + ] + } + ], + "values": [ + { + "endpoint": 0, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 0, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 0, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 2, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 4, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Current value", + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Up", + "propertyName": "Up", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Up)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Down", + "propertyName": "Down", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Down)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "restorePrevious", + "propertyName": "restorePrevious", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Restore previous value", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 106, + "commandClassName": "Window Covering", + "property": "currentValue", + "propertyKey": 23, + "propertyName": "currentValue", + "propertyKeyName": "Horizontal Slats Angle", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Current value - Horizontal Slats Angle", + "ccSpecific": { + "parameter": 23 + }, + "min": 0, + "max": 99, + "states": { + "0": "Closed (up)", + "50": "Open", + "99": "Closed (down)" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 106, + "commandClassName": "Window Covering", + "property": "targetValue", + "propertyKey": 23, + "propertyName": "targetValue", + "propertyKeyName": "Horizontal Slats Angle", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Target value - Horizontal Slats Angle", + "ccSpecific": { + "parameter": 23 + }, + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 99, + "states": { + "0": "Closed (up)", + "50": "Open", + "99": "Closed (down)" + }, + "stateful": true, + "secret": false + }, + "value": 99 + }, + { + "endpoint": 0, + "commandClass": 106, + "commandClassName": "Window Covering", + "property": "duration", + "propertyKey": 23, + "propertyName": "duration", + "propertyKeyName": "Horizontal Slats Angle", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration - Horizontal Slats Angle", + "ccSpecific": { + "parameter": 23 + }, + "stateful": true, + "secret": false + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + { + "endpoint": 0, + "commandClass": 106, + "commandClassName": "Window Covering", + "property": "open", + "propertyKey": 23, + "propertyName": "open", + "propertyKeyName": "Horizontal Slats Angle", + "ccVersion": 0, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Open - Horizontal Slats Angle", + "ccSpecific": { + "parameter": 23 + }, + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "nodeId": 12, + "value": true + }, + { + "endpoint": 0, + "commandClass": 106, + "commandClassName": "Window Covering", + "property": "close0", + "propertyKey": 23, + "propertyName": "close0", + "propertyKeyName": "Horizontal Slats Angle", + "ccVersion": 0, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Close Up - Horizontal Slats Angle", + "ccSpecific": { + "parameter": 23 + }, + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 106, + "commandClassName": "Window Covering", + "property": "close99", + "propertyKey": 23, + "propertyName": "close99", + "propertyKeyName": "Horizontal Slats Angle", + "ccVersion": 0, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Close Down - Horizontal Slats Angle", + "ccSpecific": { + "parameter": 23 + }, + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "nodeId": 12, + "value": true + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 1, + "propertyName": "Close Interval", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Auto Calibration Tightness. Lower value = tighter, Higher value = looser (MUST BE RECALIBRATED).", + "label": "Close Interval", + "default": 22, + "min": 16, + "max": 32, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 15 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 2, + "propertyName": "Reverse Direction", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Reverse Direction", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "No", + "1": "Yes" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 3, + "propertyName": "Send Reports", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Used to disable Z-Wave Reports. This is useful for systems that poll iblinds immediately after sending a position command. Disable this if an immediate report is causing iblinds motor to function improperly.", + "label": "Send Reports", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Enable", + "1": "Disable" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 4, + "propertyName": "Default ON Value", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Value that iblinds will open to by default", + "label": "Default ON Value", + "default": 50, + "min": 0, + "max": 99, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 50 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 5, + "propertyName": "Reset Button", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Disabling the reset button on the motor prevents it from accidentally being pressed", + "label": "Reset Button", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Enable", + "1": "Disable" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 6, + "propertyName": "Movement Duration", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Movement Duration", + "default": 0, + "min": 0, + "max": 100, + "unit": "seconds", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 7, + "propertyName": "Remote Calibration", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": false, + "writeable": true, + "label": "Remote Calibration", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Idle", + "1": "Start calibration" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + } + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 8, + "propertyName": "Minimum Tilt Level", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": false, + "writeable": true, + "label": "Minimum Tilt Level", + "default": 0, + "min": 0, + "max": 25, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + } + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 9, + "propertyName": "Maximum Tilt Level", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": false, + "writeable": true, + "label": "Maximum Tilt Level", + "default": 99, + "min": 75, + "max": 99, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + } + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 10, + "propertyName": "Override Response to ON Command", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": false, + "writeable": true, + "description": "Overrides Set commands with a value of 99 to use the value defined in parameter 4 (Default ON Value) instead", + "label": "Override Response to ON Command", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + } + }, + { + "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": 647 + }, + { + "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": 4 + }, + { + "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": 114 + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "level", + "propertyName": "level", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Battery level", + "min": 0, + "max": 100, + "unit": "%", + "stateful": true, + "secret": false + }, + "value": 90 + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "isLow", + "propertyName": "isLow", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Low battery level", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Library type", + "states": { + "0": "Unknown", + "1": "Static Controller", + "2": "Controller", + "3": "Enhanced Slave", + "4": "Slave", + "5": "Installer", + "6": "Routing Slave", + "7": "Bridge Controller", + "8": "Device under Test", + "9": "N/A", + "10": "AV Remote", + "11": "AV Device" + }, + "stateful": true, + "secret": false + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "7.12" + }, + { + "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": ["3.12"] + }, + { + "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": "7.12.2" + }, + { + "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.12.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": 35 + }, + { + "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": "7.12.2" + }, + { + "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": 35 + }, + { + "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": "3.12.1" + }, + { + "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": 43707 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 3, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "Node Identify - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 4, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "Node Identify - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 5, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "Node Identify - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": "value", + "propertyName": "value", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Indicator value", + "ccSpecific": { + "indicatorId": 0 + }, + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": "identify", + "propertyName": "identify", + "ccVersion": 3, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Identify", + "stateful": true, + "secret": false + } + } + ], + "isFrequentListening": "1000ms", + "maxDataRate": 100000, + "supportedDataRates": [40000, 100000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "zwavePlusNodeType": 0, + "zwavePlusRoleType": 7, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 7, + "label": "Motor Control Class C" + }, + "mandatorySupportedCCs": [32, 38, 37, 114, 134], + "mandatoryControlledCCs": [] + }, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0287:0x0004:0x0072:3.12.1", + "statistics": { + "commandsTX": 109, + "commandsRX": 101, + "commandsDroppedRX": 2, + "commandsDroppedTX": 0, + "timeoutResponse": 8, + "rtt": 1217.2, + "rssi": -43, + "lwr": { + "protocolDataRate": 2, + "repeaters": [], + "rssi": -45, + "repeaterRSSI": [] + } + }, + "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 66992a5f4d05..ae38c82a75ce 100644 --- a/tests/components/zwave_js/test_cover.py +++ b/tests/components/zwave_js/test_cover.py @@ -20,12 +20,15 @@ from homeassistant.components.cover import ( SERVICE_SET_COVER_POSITION, SERVICE_SET_COVER_TILT_POSITION, SERVICE_STOP_COVER, + SERVICE_STOP_COVER_TILT, CoverDeviceClass, + CoverEntityFeature, ) from homeassistant.components.zwave_js.helpers import ZwaveValueMatcher from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, STATE_CLOSED, STATE_CLOSING, STATE_OPEN, @@ -688,3 +691,107 @@ async def test_fibaro_fgr222_shutter_cover_no_tilt( 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_iblinds_v3_cover( + hass: HomeAssistant, client, iblinds_v3, integration +) -> None: + """Test iBlinds v3 cover which uses Window Covering CC.""" + entity_id = "cover.window_blind_controller_horizontal_slats_angle" + state = hass.states.get(entity_id) + assert state + # This device has no state because there is no position value + assert state.state == STATE_UNKNOWN + assert state.attributes[ATTR_SUPPORTED_FEATURES] == ( + CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.SET_TILT_POSITION + | CoverEntityFeature.STOP_TILT + ) + assert ATTR_CURRENT_POSITION not in state.attributes + assert ATTR_CURRENT_TILT_POSITION in state.attributes + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 0 + + await hass.services.async_call( + DOMAIN, + SERVICE_CLOSE_COVER_TILT, + {ATTR_ENTITY_ID: entity_id}, + 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"] == 12 + assert args["valueId"] == { + "endpoint": 0, + "commandClass": 106, + "property": "targetValue", + "propertyKey": 23, + } + assert args["value"] == 0 + + client.async_send_command.reset_mock() + + await hass.services.async_call( + DOMAIN, + SERVICE_OPEN_COVER_TILT, + {ATTR_ENTITY_ID: entity_id}, + 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"] == 12 + assert args["valueId"] == { + "endpoint": 0, + "commandClass": 106, + "property": "targetValue", + "propertyKey": 23, + } + assert args["value"] == 50 + + client.async_send_command.reset_mock() + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_COVER_TILT_POSITION, + {ATTR_ENTITY_ID: entity_id, 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"] == 12 + assert args["valueId"] == { + "endpoint": 0, + "commandClass": 106, + "property": "targetValue", + "propertyKey": 23, + } + assert args["value"] == 12 + + client.async_send_command.reset_mock() + + await hass.services.async_call( + DOMAIN, + SERVICE_STOP_COVER_TILT, + {ATTR_ENTITY_ID: entity_id}, + 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"] == 12 + assert args["valueId"] == { + "endpoint": 0, + "commandClass": 106, + "property": "open", + "propertyKey": 23, + } + assert args["value"] is False + + client.async_send_command.reset_mock() From 55c2bb59c8b852d35b3ae247e1a87b3887c6eb40 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 30 May 2023 17:45:33 +0200 Subject: [PATCH 688/822] Fix race in rfxtrx config flow (#93804) * Fix race in rfxtrx config flow * Add timeout * Use async_timeout.timeout --- .../components/rfxtrx/config_flow.py | 45 ++++++++++++++++--- 1 file changed, 38 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/rfxtrx/config_flow.py b/homeassistant/components/rfxtrx/config_flow.py index 2aa3bd20b8cc..2e054ce47245 100644 --- a/homeassistant/components/rfxtrx/config_flow.py +++ b/homeassistant/components/rfxtrx/config_flow.py @@ -2,12 +2,14 @@ from __future__ import annotations import asyncio +from contextlib import suppress import copy import itertools import os from typing import Any, TypedDict, cast import RFXtrx as rfxtrxmod +from async_timeout import timeout import serial import serial.tools.list_ports import voluptuous as vol @@ -346,34 +348,57 @@ class OptionsFlow(config_entries.OptionsFlow): entity_migration_map[new_entity_id] = entry @callback - def _handle_state_change( + def _handle_state_removed( entity_id: str, old_state: State | None, new_state: State | None ) -> None: # Wait for entities to finish cleanup - if new_state is None and entity_id in pending_entities: - pending_entities.remove(entity_id) - if not pending_entities: + if new_state is None and entity_id in entities_to_be_removed: + entities_to_be_removed.remove(entity_id) + if not entities_to_be_removed: wait_for_entities.set() # Create a set with entities to be removed which are currently in the state # machine - pending_entities = { + entities_to_be_removed = { entry.entity_id for entry in entity_migration_map.values() if not self.hass.states.async_available(entry.entity_id) } wait_for_entities = asyncio.Event() remove_track_state_changes = async_track_state_change( - self.hass, pending_entities, _handle_state_change + self.hass, entities_to_be_removed, _handle_state_removed ) for entry in entity_migration_map.values(): entity_registry.async_remove(entry.entity_id) # Wait for entities to finish cleanup - await wait_for_entities.wait() + with suppress(asyncio.TimeoutError): + async with timeout(10): + await wait_for_entities.wait() remove_track_state_changes() + @callback + def _handle_state_added( + entity_id: str, old_state: State | None, new_state: State | None + ) -> None: + # Wait for entities to be added + if old_state is None and entity_id in entities_to_be_added: + entities_to_be_added.remove(entity_id) + if not entities_to_be_added: + wait_for_entities.set() + + # Create a set with entities to be added to the state machine + entities_to_be_added = { + entry.entity_id + for entry in entity_migration_map.values() + if self.hass.states.async_available(entry.entity_id) + } + wait_for_entities = asyncio.Event() + remove_track_state_changes = async_track_state_change( + self.hass, entities_to_be_added, _handle_state_added + ) + for entity_id, entry in entity_migration_map.items(): entity_registry.async_update_entity( entity_id, @@ -382,6 +407,12 @@ class OptionsFlow(config_entries.OptionsFlow): icon=entry.icon, ) + # Wait for entities to finish renaming + with suppress(asyncio.TimeoutError): + async with timeout(10): + await wait_for_entities.wait() + remove_track_state_changes() + device_registry.async_remove_device(old_device) def _can_add_device(self, new_rfx_obj: rfxtrxmod.RFXtrxEvent) -> bool: From 65187c6f11222bffacf358396de09d6dde9566d6 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Tue, 30 May 2023 11:49:55 -0400 Subject: [PATCH 689/822] Add zwave config parameter entities (#92223) * Add zwave config parameter entities * Remove unused entity const * remove unusued imports * review comments * switch to reserved values * fix test --- .../components/zwave_js/binary_sensor.py | 23 ++++ homeassistant/components/zwave_js/const.py | 3 + .../components/zwave_js/discovery.py | 120 +++++++++++++----- homeassistant/components/zwave_js/entity.py | 19 +++ homeassistant/components/zwave_js/number.py | 42 +++++- homeassistant/components/zwave_js/select.py | 23 ++++ homeassistant/components/zwave_js/sensor.py | 26 ++-- homeassistant/components/zwave_js/switch.py | 38 +++++- tests/components/zwave_js/common.py | 3 - .../components/zwave_js/test_binary_sensor.py | 26 ++++ tests/components/zwave_js/test_init.py | 4 +- tests/components/zwave_js/test_number.py | 36 +++++- tests/components/zwave_js/test_select.py | 26 ++++ tests/components/zwave_js/test_sensor.py | 71 ++++------- tests/components/zwave_js/test_switch.py | 77 ++++++++++- 15 files changed, 431 insertions(+), 106 deletions(-) diff --git a/homeassistant/components/zwave_js/binary_sensor.py b/homeassistant/components/zwave_js/binary_sensor.py index 0051d6ccbf2b..ef5cdd1b1d22 100644 --- a/homeassistant/components/zwave_js/binary_sensor.py +++ b/homeassistant/components/zwave_js/binary_sensor.py @@ -315,6 +315,10 @@ async def async_setup_entry( config_entry, driver, info, property_description ) ) + elif info.platform_hint == "config_parameter": + entities.append( + ZWaveConfigParameterBinarySensor(config_entry, driver, info) + ) else: # boolean sensor entities.append(ZWaveBooleanBinarySensor(config_entry, driver, info)) @@ -411,3 +415,22 @@ class ZWavePropertyBinarySensor(ZWaveBaseEntity, BinarySensorEntity): if self.info.primary_value.value is None: return None return self.info.primary_value.value in self.entity_description.on_states + + +class ZWaveConfigParameterBinarySensor(ZWaveBooleanBinarySensor): + """Representation of a Z-Wave config parameter binary sensor.""" + + _attr_entity_category = EntityCategory.DIAGNOSTIC + + def __init__( + self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + ) -> None: + """Initialize a ZWaveConfigParameterBinarySensor entity.""" + super().__init__(config_entry, driver, info) + + property_key_name = self.info.primary_value.property_key_name + # Entity class attributes + self._attr_name = self.generate_name( + alternate_value_name=self.info.primary_value.property_name, + additional_info=[property_key_name] if property_key_name else None, + ) diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index 27d4dc003687..6a85780ce78c 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -36,6 +36,9 @@ EVENT_DEVICE_ADDED_TO_REGISTRY = f"{DOMAIN}_device_added_to_registry" LOGGER = logging.getLogger(__package__) +# constants extra state attributes +ATTR_RESERVED_VALUES = "reserved_values" # ConfigurationValue number entities + # constants for events ZWAVE_JS_VALUE_NOTIFICATION_EVENT = f"{DOMAIN}_value_notification" ZWAVE_JS_NOTIFICATION_EVENT = f"{DOMAIN}_notification" diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 2bd7210b0431..c17cbace6573 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Generator from dataclasses import asdict, dataclass, field -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast from awesomeversion import AwesomeVersion from zwave_js_server.const import ( @@ -41,7 +41,11 @@ from zwave_js_server.const.command_class.thermostat import ( from zwave_js_server.exceptions import UnknownValueData from zwave_js_server.model.device_class import DeviceClassItem from zwave_js_server.model.node import Node as ZwaveNode -from zwave_js_server.model.value import Value as ZwaveValue +from zwave_js_server.model.value import ( + ConfigurationValue, + ConfigurationValueType, + Value as ZwaveValue, +) from homeassistant.backports.enum import StrEnum from homeassistant.const import EntityCategory, Platform @@ -205,32 +209,6 @@ class ZWaveDiscoverySchema: entity_category: EntityCategory | None = None -def get_config_parameter_discovery_schema( - property_: set[str | int] | None = None, - property_name: set[str] | None = None, - property_key: set[str | int | None] | None = None, - **kwargs: Any, -) -> ZWaveDiscoverySchema: - """Return a discovery schema for a config parameter. - - Supports all keyword arguments to ZWaveValueDiscoverySchema except platform, hint, - and primary_value. - """ - return ZWaveDiscoverySchema( - platform=Platform.SENSOR, - hint="config_parameter", - primary_value=ZWaveValueDiscoverySchema( - command_class={CommandClass.CONFIGURATION}, - property=property_, - property_name=property_name, - property_key=property_key, - type={ValueType.NUMBER}, - ), - entity_registry_enabled_default=False, - **kwargs, - ) - - DOOR_LOCK_CURRENT_MODE_SCHEMA = ZWaveValueDiscoverySchema( command_class={CommandClass.DOOR_LOCK}, property={CURRENT_MODE_PROPERTY}, @@ -596,13 +574,6 @@ DISCOVERY_SCHEMAS = [ ), absent_values=[SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA], ), - # ====== START OF CONFIG PARAMETER SPECIFIC MAPPING SCHEMAS ======= - # Door lock mode config parameter. Functionality equivalent to Notification CC - # list sensors. - get_config_parameter_discovery_schema( - property_name={"Door lock mode"}, - device_class_generic={"Entry Control"}, - ), # ====== START OF GENERIC MAPPING SCHEMAS ======= # locks # Door Lock CC @@ -1112,6 +1083,85 @@ def async_discover_single_value( # by other schemas/platforms return + if value.command_class == CommandClass.CONFIGURATION: + yield from async_discover_single_configuration_value( + cast(ConfigurationValue, value) + ) + + +@callback +def async_discover_single_configuration_value( + value: ConfigurationValue, +) -> Generator[ZwaveDiscoveryInfo, None, None]: + """Run discovery on a single ZWave configuration value and return matching schema info.""" + if value.metadata.writeable and value.metadata.readable: + if value.configuration_value_type == ConfigurationValueType.ENUMERATED: + yield ZwaveDiscoveryInfo( + node=value.node, + primary_value=value, + assumed_state=False, + platform=Platform.SELECT, + platform_hint="config_parameter", + platform_data=None, + additional_value_ids_to_watch=set(), + entity_registry_enabled_default=False, + ) + elif value.configuration_value_type in ( + ConfigurationValueType.RANGE, + ConfigurationValueType.MANUAL_ENTRY, + ): + if value.metadata.type == ValueType.BOOLEAN or ( + value.metadata.min == 0 and value.metadata.max == 1 + ): + yield ZwaveDiscoveryInfo( + node=value.node, + primary_value=value, + assumed_state=False, + platform=Platform.SWITCH, + platform_hint="config_parameter", + platform_data=None, + additional_value_ids_to_watch=set(), + entity_registry_enabled_default=False, + ) + else: + yield ZwaveDiscoveryInfo( + node=value.node, + primary_value=value, + assumed_state=False, + platform=Platform.NUMBER, + platform_hint="config_parameter", + platform_data=None, + additional_value_ids_to_watch=set(), + entity_registry_enabled_default=False, + ) + elif not value.metadata.writeable and value.metadata.readable: + if value.metadata.type == ValueType.BOOLEAN or ( + value.metadata.min == 0 + and value.metadata.max == 1 + and not value.metadata.states + ): + yield ZwaveDiscoveryInfo( + node=value.node, + primary_value=value, + assumed_state=False, + platform=Platform.BINARY_SENSOR, + platform_hint="config_parameter", + platform_data=None, + additional_value_ids_to_watch=set(), + entity_registry_enabled_default=False, + ) + else: + yield ZwaveDiscoveryInfo( + node=value.node, + primary_value=value, + assumed_state=False, + platform=Platform.SENSOR, + platform_hint="config_parameter", + platform_data=None, + additional_value_ids_to_watch=set(), + entity_registry_enabled_default=False, + ) + @callback def check_value(value: ZwaveValue, schema: ZWaveValueDiscoverySchema) -> bool: diff --git a/homeassistant/components/zwave_js/entity.py b/homeassistant/components/zwave_js/entity.py index de6adda121af..ba2d753112cd 100644 --- a/homeassistant/components/zwave_js/entity.py +++ b/homeassistant/components/zwave_js/entity.py @@ -2,13 +2,16 @@ from __future__ import annotations from collections.abc import Sequence +from typing import Any from zwave_js_server.const import NodeStatus +from zwave_js_server.exceptions import BaseZwaveJSServerError from zwave_js_server.model.driver import Driver from zwave_js_server.model.value import Value as ZwaveValue, get_value_id_str from homeassistant.config_entries import ConfigEntry from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo, Entity @@ -292,3 +295,19 @@ class ZWaveBaseEntity(Entity): ): self.watched_value_ids.add(return_value.value_id) return return_value + + async def _async_set_value( + self, + value: ZwaveValue, + new_value: Any, + options: dict | None = None, + wait_for_result: bool | None = None, + ) -> bool | None: + """Set value on node.""" + try: + return await self.info.node.async_set_value( + value, new_value, options=options, wait_for_result=wait_for_result + ) + except BaseZwaveJSServerError as err: + LOGGER.error("Unable to set value %s: %s", value.value_id, err) + raise HomeAssistantError from err diff --git a/homeassistant/components/zwave_js/number.py b/homeassistant/components/zwave_js/number.py index 52302c64b33b..4cffa7a85221 100644 --- a/homeassistant/components/zwave_js/number.py +++ b/homeassistant/components/zwave_js/number.py @@ -1,7 +1,8 @@ """Support for Z-Wave controls using the number platform.""" from __future__ import annotations -from typing import cast +from collections.abc import Mapping +from typing import Any, cast from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const import TARGET_VALUE_PROPERTY @@ -10,12 +11,13 @@ from zwave_js_server.model.value import Value from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, NumberEntity from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DATA_CLIENT, DOMAIN +from .const import ATTR_RESERVED_VALUES, DATA_CLIENT, DOMAIN from .discovery import ZwaveDiscoveryInfo from .entity import ZWaveBaseEntity @@ -38,6 +40,10 @@ async def async_setup_entry( entities: list[ZWaveBaseEntity] = [] if info.platform_hint == "volume": entities.append(ZwaveVolumeNumberEntity(config_entry, driver, info)) + elif info.platform_hint == "config_parameter": + entities.append( + ZWaveConfigParameterNumberEntity(config_entry, driver, info) + ) else: entities.append(ZwaveNumberEntity(config_entry, driver, info)) async_add_entities(entities) @@ -98,7 +104,37 @@ class ZwaveNumberEntity(ZWaveBaseEntity, NumberEntity): """Set new value.""" if (target_value := self._target_value) is None: raise HomeAssistantError("Missing target value on device.") - await self.info.node.async_set_value(target_value, value) + await self._async_set_value(target_value, value) + + +class ZWaveConfigParameterNumberEntity(ZwaveNumberEntity): + """Representation of a Z-Wave config parameter number.""" + + _attr_entity_category = EntityCategory.CONFIG + + def __init__( + self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + ) -> None: + """Initialize a ZWaveConfigParameterNumber entity.""" + super().__init__(config_entry, driver, info) + + property_key_name = self.info.primary_value.property_key_name + # Entity class attributes + self._attr_name = self.generate_name( + alternate_value_name=self.info.primary_value.property_name, + additional_info=[property_key_name] if property_key_name else None, + ) + + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + """Return extra state attributes for entity.""" + if not self.info.primary_value.metadata.states: + return None + return { + ATTR_RESERVED_VALUES: { + int(k): v for k, v in self.info.primary_value.metadata.states.items() + } + } class ZwaveVolumeNumberEntity(ZWaveBaseEntity, NumberEntity): diff --git a/homeassistant/components/zwave_js/select.py b/homeassistant/components/zwave_js/select.py index f74c98117b49..49d4fec4a20a 100644 --- a/homeassistant/components/zwave_js/select.py +++ b/homeassistant/components/zwave_js/select.py @@ -42,6 +42,10 @@ async def async_setup_entry( entities.append( ZwaveMultilevelSwitchSelectEntity(config_entry, driver, info) ) + elif info.platform_hint == "config_parameter": + entities.append( + ZWaveConfigParameterSelectEntity(config_entry, driver, info) + ) else: entities.append(ZwaveSelectEntity(config_entry, driver, info)) async_add_entities(entities) @@ -91,6 +95,25 @@ class ZwaveSelectEntity(ZWaveBaseEntity, SelectEntity): await self.info.node.async_set_value(self.info.primary_value, int(key)) +class ZWaveConfigParameterSelectEntity(ZwaveSelectEntity): + """Representation of a Z-Wave config parameter select.""" + + _attr_entity_category = EntityCategory.CONFIG + + def __init__( + self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + ) -> None: + """Initialize a ZWaveConfigParameterSelect entity.""" + super().__init__(config_entry, driver, info) + + property_key_name = self.info.primary_value.property_key_name + # Entity class attributes + self._attr_name = self.generate_name( + alternate_value_name=self.info.primary_value.property_name, + additional_info=[property_key_name] if property_key_name else None, + ) + + class ZwaveDefaultToneSelectEntity(ZWaveBaseEntity, SelectEntity): """Representation of a Z-Wave default tone select entity.""" diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 469f66b792b0..1a292cb17ef4 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -6,14 +6,14 @@ from typing import cast import voluptuous as vol from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.const import CommandClass, ConfigurationValueType, NodeStatus +from zwave_js_server.const import CommandClass, NodeStatus from zwave_js_server.const.command_class.meter import ( RESET_METER_OPTION_TARGET_VALUE, RESET_METER_OPTION_TYPE, ) from zwave_js_server.model.driver import Driver from zwave_js_server.model.node import Node as ZwaveNode -from zwave_js_server.model.value import ConfigurationValue +from zwave_js_server.model.value import ConfigurationValue, ConfigurationValueType from zwave_js_server.util.command_class.meter import get_meter_type from homeassistant.components.sensor import ( @@ -490,6 +490,13 @@ class ZWaveListSensor(ZwaveSensor): 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.""" @@ -499,13 +506,6 @@ class ZWaveListSensor(ZwaveSensor): return SensorDeviceClass.ENUM return None - @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 extra_state_attributes(self) -> dict[str, str] | None: """Return the device specific state attributes.""" @@ -518,6 +518,8 @@ class ZWaveListSensor(ZwaveSensor): class ZWaveConfigParameterSensor(ZWaveListSensor): """Representation of a Z-Wave config parameter sensor.""" + _attr_entity_category = EntityCategory.DIAGNOSTIC + def __init__( self, config_entry: ConfigEntry, @@ -537,7 +539,6 @@ class ZWaveConfigParameterSensor(ZWaveListSensor): self._attr_name = self.generate_name( alternate_value_name=self.info.primary_value.property_name, additional_info=[property_key_name] if property_key_name else None, - name_prefix="Config parameter", ) @property @@ -555,10 +556,7 @@ class ZWaveConfigParameterSensor(ZWaveListSensor): @property def extra_state_attributes(self) -> dict[str, str] | None: """Return the device specific state attributes.""" - if ( - self._primary_value.configuration_value_type == ConfigurationValueType.RANGE - or (value := self.info.primary_value.value) is None - ): + if (value := self.info.primary_value.value) is None: return None # add the value's int value as property for multi-value (list) items return {ATTR_VALUE: value} diff --git a/homeassistant/components/zwave_js/switch.py b/homeassistant/components/zwave_js/switch.py index ce640f92140d..409bcd1dbb73 100644 --- a/homeassistant/components/zwave_js/switch.py +++ b/homeassistant/components/zwave_js/switch.py @@ -12,6 +12,7 @@ from zwave_js_server.model.driver import Driver from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SwitchEntity from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -41,6 +42,8 @@ async def async_setup_entry( entities.append( ZWaveBarrierEventSignalingSwitch(config_entry, driver, info) ) + elif info.platform_hint == "config_parameter": + entities.append(ZWaveConfigParameterSwitch(config_entry, driver, info)) elif info.platform_hint == "indicator": entities.append(ZWaveIndicatorSwitch(config_entry, driver, info)) else: @@ -79,12 +82,12 @@ class ZWaveSwitch(ZWaveBaseEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" if self._target_value is not None: - await self.info.node.async_set_value(self._target_value, True) + await self._async_set_value(self._target_value, True) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" if self._target_value is not None: - await self.info.node.async_set_value(self._target_value, False) + await self._async_set_value(self._target_value, False) class ZWaveIndicatorSwitch(ZWaveSwitch): @@ -129,7 +132,7 @@ class ZWaveBarrierEventSignalingSwitch(ZWaveBaseEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" - await self.info.node.async_set_value( + await self._async_set_value( self.info.primary_value, BarrierEventSignalingSubsystemState.ON ) # this value is not refreshed, so assume success @@ -138,7 +141,7 @@ class ZWaveBarrierEventSignalingSwitch(ZWaveBaseEntity, SwitchEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" - await self.info.node.async_set_value( + await self._async_set_value( self.info.primary_value, BarrierEventSignalingSubsystemState.OFF ) # this value is not refreshed, so assume success @@ -152,3 +155,30 @@ class ZWaveBarrierEventSignalingSwitch(ZWaveBaseEntity, SwitchEntity): self._state = ( self.info.primary_value.value == BarrierEventSignalingSubsystemState.ON ) + + +class ZWaveConfigParameterSwitch(ZWaveSwitch): + """Representation of a Z-Wave config parameter switch.""" + + _attr_entity_category = EntityCategory.CONFIG + + def __init__( + self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + ) -> None: + """Initialize a ZWaveConfigParameterSwitch entity.""" + super().__init__(config_entry, driver, info) + + property_key_name = self.info.primary_value.property_key_name + # Entity class attributes + self._attr_name = self.generate_name( + alternate_value_name=self.info.primary_value.property_name, + additional_info=[property_key_name] if property_key_name else None, + ) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + await self._async_set_value(self.info.primary_value, 1) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + await self._async_set_value(self.info.primary_value, 0) diff --git a/tests/components/zwave_js/common.py b/tests/components/zwave_js/common.py index aee2322b2afb..3da63419a4b1 100644 --- a/tests/components/zwave_js/common.py +++ b/tests/components/zwave_js/common.py @@ -39,9 +39,6 @@ BULB_6_MULTI_COLOR_LIGHT_ENTITY = "light.bulb_6_multi_color" EATON_RF9640_ENTITY = "light.allloaddimmer" AEON_SMART_SWITCH_LIGHT_ENTITY = "light.smart_switch_6" SCHLAGE_BE469_LOCK_ENTITY = "lock.touchscreen_deadbolt" -ID_LOCK_CONFIG_PARAMETER_SENSOR = ( - "sensor.z_wave_module_for_id_lock_150_and_101_config_parameter_door_lock_mode" -) ZEN_31_ENTITY = "light.kitchen_under_cabinet_lights" METER_ENERGY_SENSOR = "sensor.smart_switch_6_electric_consumed_kwh" METER_VOLTAGE_SENSOR = "sensor.smart_switch_6_electric_consumed_v" diff --git a/tests/components/zwave_js/test_binary_sensor.py b/tests/components/zwave_js/test_binary_sensor.py index ca7d20aa9cf8..a3ae9954d2ff 100644 --- a/tests/components/zwave_js/test_binary_sensor.py +++ b/tests/components/zwave_js/test_binary_sensor.py @@ -259,3 +259,29 @@ async def test_property_sensor_door_status( state = hass.states.get(PROPERTY_DOOR_STATUS_BINARY_SENSOR) assert state assert state.state == STATE_UNKNOWN + + +async def test_config_parameter_binary_sensor( + hass: HomeAssistant, climate_adc_t3000, integration +) -> None: + """Test config parameter binary sensor is created.""" + binary_sensor_entity_id = "binary_sensor.adc_t3000_system_configuration_override" + ent_reg = er.async_get(hass) + entity_entry = ent_reg.async_get(binary_sensor_entity_id) + assert entity_entry + assert entity_entry.disabled + assert entity_entry.entity_category == EntityCategory.DIAGNOSTIC + + updated_entry = ent_reg.async_update_entity( + binary_sensor_entity_id, **{"disabled_by": None} + ) + assert updated_entry != entity_entry + assert updated_entry.disabled is False + + # reload integration and check if entity is correctly there + await hass.config_entries.async_reload(integration.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(binary_sensor_entity_id) + assert state + assert state.state == STATE_OFF diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 45cf23a3f1e4..2b1e16a6c120 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -963,7 +963,7 @@ async def test_removed_device( # Check how many entities there are ent_reg = er.async_get(hass) entity_entries = er.async_entries_for_config_entry(ent_reg, integration.entry_id) - assert len(entity_entries) == 36 + assert len(entity_entries) == 62 # Remove a node and reload the entry old_node = driver.controller.nodes.pop(13) @@ -975,7 +975,7 @@ async def test_removed_device( device_entries = dr.async_entries_for_config_entry(dev_reg, integration.entry_id) assert len(device_entries) == 2 entity_entries = er.async_entries_for_config_entry(ent_reg, integration.entry_id) - assert len(entity_entries) == 23 + assert len(entity_entries) == 38 assert dev_reg.async_get_device({get_device_id(driver, old_node)}) is None diff --git a/tests/components/zwave_js/test_number.py b/tests/components/zwave_js/test_number.py index d94af41c9d63..7229d10ebad3 100644 --- a/tests/components/zwave_js/test_number.py +++ b/tests/components/zwave_js/test_number.py @@ -4,7 +4,7 @@ from unittest.mock import patch import pytest from zwave_js_server.event import Event -from homeassistant.const import STATE_UNKNOWN +from homeassistant.const import STATE_UNKNOWN, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er @@ -229,3 +229,37 @@ async def test_disabled_basic_number( assert entity_entry assert entity_entry.disabled assert entity_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + +async def test_config_parameter_number( + hass: HomeAssistant, climate_adc_t3000, integration +) -> None: + """Test config parameter number is created.""" + number_entity_id = "number.adc_t3000_heat_staging_delay" + number_with_states_entity_id = "number.adc_t3000_calibration_temperature" + ent_reg = er.async_get(hass) + for entity_id in (number_entity_id, number_with_states_entity_id): + entity_entry = ent_reg.async_get(entity_id) + assert entity_entry + assert entity_entry.disabled + assert entity_entry.entity_category == EntityCategory.CONFIG + + for entity_id in (number_entity_id, number_with_states_entity_id): + updated_entry = ent_reg.async_update_entity(entity_id, **{"disabled_by": None}) + assert updated_entry != entity_entry + assert updated_entry.disabled is False + + # reload integration and check if entity is correctly there + await hass.config_entries.async_reload(integration.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(number_entity_id) + assert state + assert state.state == "30.0" + assert "reserved_values" not in state.attributes + + state = hass.states.get(number_with_states_entity_id) + assert state + assert state.state == "0.0" + assert "reserved_values" in state.attributes + assert state.attributes["reserved_values"] == {-1: "Disabled"} diff --git a/tests/components/zwave_js/test_select.py b/tests/components/zwave_js/test_select.py index dc367e637fc0..c63f0c429fd4 100644 --- a/tests/components/zwave_js/test_select.py +++ b/tests/components/zwave_js/test_select.py @@ -294,3 +294,29 @@ async def test_multilevel_switch_select_no_value( assert state assert state.state == STATE_UNKNOWN + + +async def test_config_parameter_select( + hass: HomeAssistant, climate_adc_t3000, integration +) -> None: + """Test config parameter select is created.""" + select_entity_id = "select.adc_t3000_hvac_system_type" + ent_reg = er.async_get(hass) + entity_entry = ent_reg.async_get(select_entity_id) + assert entity_entry + assert entity_entry.disabled + assert entity_entry.entity_category == EntityCategory.CONFIG + + updated_entry = ent_reg.async_update_entity( + select_entity_id, **{"disabled_by": None} + ) + assert updated_entry != entity_entry + assert updated_entry.disabled is False + + # reload integration and check if entity is correctly there + await hass.config_entries.async_reload(integration.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(select_entity_id) + assert state + assert state.state == "Normal" diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index 6056d6391811..783dd14f2347 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -45,7 +45,6 @@ from .common import ( CURRENT_SENSOR, ENERGY_SENSOR, HUMIDITY_SENSOR, - ID_LOCK_CONFIG_PARAMETER_SENSOR, METER_ENERGY_SENSOR, NOTIFICATION_MOTION_SENSOR, POWER_SENSOR, @@ -188,7 +187,9 @@ async def test_disabled_notification_sensor( state = hass.states.get(NOTIFICATION_MOTION_SENSOR) assert state.state == "Motion detection" - assert state.attributes["value"] == 8 + assert state.attributes[ATTR_VALUE] == 8 + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.ENUM + assert state.attributes[ATTR_OPTIONS] == ["idle", "Motion detection"] event = Event( "value updated", @@ -218,13 +219,34 @@ async def test_disabled_notification_sensor( async def test_config_parameter_sensor( - hass: HomeAssistant, lock_id_lock_as_id150, integration + hass: HomeAssistant, climate_adc_t3000, lock_id_lock_as_id150, integration ) -> None: """Test config parameter sensor is created.""" + sensor_entity_id = "sensor.adc_t3000_system_configuration_cool_stages" + sensor_with_states_entity_id = "sensor.adc_t3000_power_source" ent_reg = er.async_get(hass) - entity_entry = ent_reg.async_get(ID_LOCK_CONFIG_PARAMETER_SENSOR) - assert entity_entry - assert entity_entry.disabled + for entity_id in (sensor_entity_id, sensor_with_states_entity_id): + entity_entry = ent_reg.async_get(entity_id) + assert entity_entry + assert entity_entry.disabled + assert entity_entry.entity_category == EntityCategory.DIAGNOSTIC + + for entity_id in (sensor_entity_id, sensor_with_states_entity_id): + updated_entry = ent_reg.async_update_entity(entity_id, **{"disabled_by": None}) + assert updated_entry != entity_entry + assert updated_entry.disabled is False + + # reload integration and check if entity is correctly there + await hass.config_entries.async_reload(integration.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(sensor_entity_id) + assert state + assert state.state == "1" + + state = hass.states.get(sensor_with_states_entity_id) + assert state + assert state.state == "C-Wire" updated_entry = ent_reg.async_update_entity( entity_entry.entity_id, **{"disabled_by": None} @@ -236,43 +258,6 @@ async def test_config_parameter_sensor( await hass.config_entries.async_reload(integration.entry_id) await hass.async_block_till_done() - state = hass.states.get(ID_LOCK_CONFIG_PARAMETER_SENSOR) - assert state - assert state.state == "Disable Away Manual Lock" - assert state.attributes[ATTR_VALUE] == 0 - assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.ENUM - assert state.attributes[ATTR_OPTIONS] == [ - "Disable Away Manual Lock", - "Disable Away Auto Lock", - "Enable Away Manual Lock", - "Enable Away Auto Lock", - ] - - event = Event( - "value updated", - { - "source": "node", - "event": "value updated", - "nodeId": lock_id_lock_as_id150.node_id, - "args": { - "commandClassName": "Configuration", - "commandClass": 112, - "endpoint": 0, - "property": 1, - "newValue": None, - "prevValue": 0, - "propertyName": "Door lock mode", - }, - }, - ) - - lock_id_lock_as_id150.receive_event(event) - await hass.async_block_till_done() - state = hass.states.get(ID_LOCK_CONFIG_PARAMETER_SENSOR) - assert state - assert state.state == STATE_UNKNOWN - assert ATTR_VALUE not in state.attributes - async def test_node_status_sensor( hass: HomeAssistant, client, lock_id_lock_as_id150, integration diff --git a/tests/components/zwave_js/test_switch.py b/tests/components/zwave_js/test_switch.py index 87ad7838ff18..ebf7d9f441fa 100644 --- a/tests/components/zwave_js/test_switch.py +++ b/tests/components/zwave_js/test_switch.py @@ -1,12 +1,16 @@ """Test the Z-Wave JS switch platform.""" +import pytest from zwave_js_server.const import CURRENT_VALUE_PROPERTY, CommandClass from zwave_js_server.event import Event +from zwave_js_server.exceptions import FailedZWaveCommand from zwave_js_server.model.node import Node from homeassistant.components.switch import DOMAIN, SERVICE_TURN_OFF, SERVICE_TURN_ON from homeassistant.components.zwave_js.helpers import ZwaveValueMatcher -from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN, EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er from .common import SWITCH_ENTITY, replace_value_of_zwave_value @@ -209,3 +213,74 @@ async def test_switch_no_value( assert state assert state.state == STATE_UNKNOWN + + +async def test_config_parameter_switch( + hass: HomeAssistant, hank_binary_switch, integration, client +) -> None: + """Test config parameter switch is created.""" + switch_entity_id = "switch.smart_plug_with_two_usb_ports_overload_protection" + ent_reg = er.async_get(hass) + entity_entry = ent_reg.async_get(switch_entity_id) + assert entity_entry + assert entity_entry.disabled + + updated_entry = ent_reg.async_update_entity( + switch_entity_id, **{"disabled_by": None} + ) + assert updated_entry != entity_entry + assert updated_entry.disabled is False + assert entity_entry.entity_category == EntityCategory.CONFIG + + # reload integration and check if entity is correctly there + await hass.config_entries.async_reload(integration.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(switch_entity_id) + assert state + assert state.state == STATE_ON + + client.async_send_command.reset_mock() + + # Test turning on + await hass.services.async_call( + DOMAIN, SERVICE_TURN_ON, {"entity_id": switch_entity_id}, 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"] == hank_binary_switch.node_id + assert args["value"] == 1 + assert args["valueId"] == { + "commandClass": 112, + "endpoint": 0, + "property": 20, + } + + client.async_send_command.reset_mock() + + # Test turning off + await hass.services.async_call( + DOMAIN, SERVICE_TURN_OFF, {"entity_id": switch_entity_id}, 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"] == hank_binary_switch.node_id + assert args["value"] == 0 + assert args["valueId"] == { + "commandClass": 112, + "endpoint": 0, + "property": 20, + } + + client.async_send_command.reset_mock() + client.async_send_command.side_effect = FailedZWaveCommand("test", 1, "test") + + # Test turning off error raises proper exception + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, SERVICE_TURN_OFF, {"entity_id": switch_entity_id}, blocking=True + ) From 8e05e3850ba228c87b31dc43519384a5c283c6f3 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 30 May 2023 18:46:33 +0200 Subject: [PATCH 690/822] Deprecate Python 3.10 (#93794) --- homeassistant/const.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index f150938347d6..212000aa26d3 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -12,9 +12,9 @@ PATCH_VERSION: Final = "0.dev0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 10, 0) -REQUIRED_NEXT_PYTHON_VER: Final[tuple[int, int, int]] = (3, 10, 0) +REQUIRED_NEXT_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) # Truthy date string triggers showing related deprecation warning messages. -REQUIRED_NEXT_PYTHON_HA_RELEASE: Final = "" +REQUIRED_NEXT_PYTHON_HA_RELEASE: Final = "2023.8" # Format for platform files PLATFORM_FORMAT: Final = "{platform}.{domain}" From de3406c85667d21257a37100c558d186893b07fa Mon Sep 17 00:00:00 2001 From: Luke Date: Tue, 30 May 2023 12:51:57 -0400 Subject: [PATCH 691/822] Add more information to Roborock Status (#93636) Co-authored-by: Humberto Gontijo --- homeassistant/components/roborock/sensor.py | 17 +++++++---- .../components/roborock/strings.json | 28 +++++++++++++++++++ tests/components/roborock/test_sensor.py | 3 +- 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index da98b62c6f6b..5cf2a286213f 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from roborock.containers import RoborockStateCode from roborock.roborock_typing import DeviceProp from homeassistant.components.sensor import ( @@ -37,7 +38,7 @@ class RoborockSensorDescription( """A class that describes Roborock sensors.""" -CONSUMABLE_SENSORS = [ +SENSOR_DESCRIPTIONS = [ RoborockSensorDescription( native_unit_of_measurement=UnitOfTime.SECONDS, key="main_brush_time_left", @@ -74,9 +75,6 @@ CONSUMABLE_SENSORS = [ value_fn=lambda data: data.consumable.sensor_time_left, entity_category=EntityCategory.DIAGNOSTIC, ), -] - -CLEAN_INFORMATION_SENSORS = [ RoborockSensorDescription( native_unit_of_measurement=UnitOfTime.SECONDS, key="cleaning_time", @@ -92,6 +90,15 @@ CLEAN_INFORMATION_SENSORS = [ device_class=SensorDeviceClass.DURATION, value_fn=lambda data: data.clean_summary.clean_time, ), + RoborockSensorDescription( + key="status", + icon="mdi:information-outline", + device_class=SensorDeviceClass.ENUM, + translation_key="status", + value_fn=lambda data: data.status.state.name, + entity_category=EntityCategory.DIAGNOSTIC, + options=RoborockStateCode.keys(), + ), ] @@ -111,7 +118,7 @@ async def async_setup_entry( description, ) for device_id, coordinator in coordinators.items() - for description in CONSUMABLE_SENSORS + CLEAN_INFORMATION_SENSORS + for description in SENSOR_DESCRIPTIONS ) diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 226adab6cd53..65e09073cb50 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -43,6 +43,34 @@ "sensor_time_left": { "name": "Sensor time left" }, + "status": { + "name": "Status", + "state": { + "starting": "Starting", + "charger_disconnected": "Charger disconnected", + "idle": "Idle", + "remote_control_active": "Remote control active", + "cleaning": "Cleaning", + "returning_home": "Returning home", + "manual_mode": "Manual mode", + "charging": "Charging", + "charging_problem": "Charging problem", + "paused": "Paused", + "spot_cleaning": "Spot cleaning", + "error": "Error", + "shutting_down": "Shutting down", + "updating": "Updating", + "docking": "Docking", + "going_to_target": "Going to target", + "zoned_cleaning": "Zoned cleaning", + "segment_cleaning": "Segment cleaning", + "emptying_the_bin": "Emptying the bin", + "washing_the_mop": "Washing the mop", + "going_to_wash_the_mop": "Going to wash the mop", + "charging_complete": "Charging complete", + "device_offline": "Device offline" + } + }, "total_cleaning_time": { "name": "Total cleaning time" } diff --git a/tests/components/roborock/test_sensor.py b/tests/components/roborock/test_sensor.py index 4c774cd6e32f..106508e6062b 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")) == 6 + assert len(hass.states.async_all("sensor")) == 7 assert hass.states.get("sensor.roborock_s7_maxv_main_brush_time_left").state == str( MAIN_BRUSH_REPLACE_TIME - 74382 ) @@ -31,3 +31,4 @@ async def test_sensors(hass: HomeAssistant, setup_entry: MockConfigEntry) -> Non assert ( hass.states.get("sensor.roborock_s7_maxv_total_cleaning_time").state == "74382" ) + assert hass.states.get("sensor.roborock_s7_maxv_status").state == "charging" From c678664d9a3b8670824159b8f4a7ee86454859ee Mon Sep 17 00:00:00 2001 From: Luke Date: Tue, 30 May 2023 12:52:55 -0400 Subject: [PATCH 692/822] Add translations to Roborock fan speed (#93801) --- .../components/roborock/strings.json | 22 +++++++++++++++++++ homeassistant/components/roborock/vacuum.py | 1 + 2 files changed, 23 insertions(+) diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 65e09073cb50..f8b8dae38d2f 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -96,6 +96,28 @@ "custom": "Custom" } } + }, + "vacuum": { + "roborock": { + "state_attributes": { + "fan_speed": { + "state": { + "auto": "Auto", + "balanced": "Balanced", + "custom": "Custom", + "gentle": "Gentle", + "off": "Off", + "max": "Max", + "max_plus": "Max plus", + "medium": "Medium", + "quiet": "Quiet", + "silent": "Silent", + "standard": "Standard", + "turbo": "Turbo" + } + } + } + } } } } diff --git a/homeassistant/components/roborock/vacuum.py b/homeassistant/components/roborock/vacuum.py index 666b8488d803..9e486fa54b17 100644 --- a/homeassistant/components/roborock/vacuum.py +++ b/homeassistant/components/roborock/vacuum.py @@ -82,6 +82,7 @@ class RoborockVacuum(RoborockCoordinatedEntity, StateVacuumEntity): | VacuumEntityFeature.STATE | VacuumEntityFeature.START ) + _attr_translation_key = DOMAIN def __init__( self, From 74741c763492b5b98e8cc15f5a81b3f1d092074a Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 30 May 2023 19:37:07 +0200 Subject: [PATCH 693/822] Fix imap advanced settings not shown (#93813) --- homeassistant/components/imap/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/imap/config_flow.py b/homeassistant/components/imap/config_flow.py index 3a7f3d9d7bdd..4f8f8090aca2 100644 --- a/homeassistant/components/imap/config_flow.py +++ b/homeassistant/components/imap/config_flow.py @@ -177,7 +177,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=title, data=user_input) - schema = self.add_suggested_values_to_schema(CONFIG_SCHEMA, user_input) + schema = self.add_suggested_values_to_schema(schema, user_input) return self.async_show_form(step_id="user", data_schema=schema, errors=errors) async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: From 105608792ee37256993e925d7661fa6c283d18de Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 30 May 2023 19:39:03 +0200 Subject: [PATCH 694/822] Fix switch acting as toggle in Sensibo (#93810) * Fix switch as toogle * typing --- homeassistant/components/sensibo/switch.py | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/sensibo/switch.py b/homeassistant/components/sensibo/switch.py index 8d0dc5fba2f6..ee9c946268fe 100644 --- a/homeassistant/components/sensibo/switch.py +++ b/homeassistant/components/sensibo/switch.py @@ -138,7 +138,7 @@ class SensiboDeviceSwitch(SensiboDeviceBaseEntity, SwitchEntity): await func( self, key=self.entity_description.data_key, - value=True, + value=False, ) @property @@ -149,27 +149,25 @@ class SensiboDeviceSwitch(SensiboDeviceBaseEntity, SwitchEntity): return None @async_handle_api_call - async def async_turn_on_timer(self, key: str, value: Any) -> bool: + async def async_turn_on_timer(self, key: str, value: bool) -> bool: """Make service call to api for setting timer.""" - new_state = bool(self.device_data.ac_states["on"] is False) data = { "minutesFromNow": 60, - "acState": {**self.device_data.ac_states, "on": new_state}, + "acState": {**self.device_data.ac_states, "on": value}, } result = await self._client.async_set_timer(self._device_id, data) return bool(result.get("status") == "success") @async_handle_api_call - async def async_turn_off_timer(self, key: str, value: Any) -> bool: + async def async_turn_off_timer(self, key: str, value: bool) -> bool: """Make service call to api for deleting timer.""" result = await self._client.async_del_timer(self._device_id) return bool(result.get("status") == "success") @async_handle_api_call - async def async_turn_on_off_pure_boost(self, key: str, value: Any) -> bool: + async def async_turn_on_off_pure_boost(self, key: str, value: bool) -> bool: """Make service call to api for setting Pure Boost.""" - new_state = bool(self.device_data.pure_boost_enabled is False) - data: dict[str, Any] = {"enabled": new_state} + data: dict[str, Any] = {"enabled": value} if self.device_data.pure_measure_integration is None: data["sensitivity"] = "N" data["measurementsIntegration"] = True @@ -180,14 +178,13 @@ class SensiboDeviceSwitch(SensiboDeviceBaseEntity, SwitchEntity): return bool(result.get("status") == "success") @async_handle_api_call - async def async_turn_on_off_smart(self, key: str, value: Any) -> bool: + async def async_turn_on_off_smart(self, key: str, value: bool) -> bool: """Make service call to api for setting Climate React.""" if self.device_data.smart_type is None: raise HomeAssistantError( "Use Sensibo Enable Climate React Service once to enable switch or the" " Sensibo app" ) - new_state = bool(self.device_data.smart_on is False) - data: dict[str, Any] = {"enabled": new_state} + data: dict[str, Any] = {"enabled": value} result = await self._client.async_enable_climate_react(self._device_id, data) return bool(result.get("status") == "success") From 17d1c0733d935b8c9233325e4a59fdcb12265043 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 30 May 2023 12:41:51 -0500 Subject: [PATCH 695/822] Remove outdated HAOS check from bluetooth (#93809) --- .../components/bluetooth/__init__.py | 61 ++----------------- .../components/bluetooth/manifest.json | 1 - .../components/bluetooth/strings.json | 6 -- tests/components/bluetooth/test_init.py | 27 +------- 4 files changed, 6 insertions(+), 89 deletions(-) diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index 3e35c97ca34b..8d3bc0ae5e2f 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -6,7 +6,6 @@ import logging import platform from typing import TYPE_CHECKING -from awesomeversion import AwesomeVersion from bleak_retry_connector import BleakSlotManager from bluetooth_adapters import ( ADAPTER_ADDRESS, @@ -25,12 +24,8 @@ from bluetooth_adapters import ( from home_assistant_bluetooth import BluetoothServiceInfo, BluetoothServiceInfoBleak from homeassistant.components import usb -from homeassistant.config_entries import ( - SOURCE_IGNORE, - SOURCE_INTEGRATION_DISCOVERY, - ConfigEntry, -) -from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP +from homeassistant.config_entries import SOURCE_INTEGRATION_DISCOVERY, ConfigEntry +from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HassJob, HomeAssistant, callback as hass_callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import ( @@ -40,11 +35,7 @@ from homeassistant.helpers import ( ) from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.event import async_call_later -from homeassistant.helpers.issue_registry import ( - IssueSeverity, - async_create_issue, - async_delete_issue, -) +from homeassistant.helpers.issue_registry import async_delete_issue from homeassistant.loader import async_get_bluetooth from . import models @@ -121,8 +112,6 @@ __all__ = [ _LOGGER = logging.getLogger(__name__) -RECOMMENDED_MIN_HAOS_VERSION = AwesomeVersion("9.0.dev0") - CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) @@ -133,43 +122,6 @@ async def _async_get_adapter_from_address( return await _get_manager(hass).async_get_adapter_from_address(address) -@hass_callback -def _async_haos_is_new_enough(hass: HomeAssistant) -> bool: - """Check if the version of Home Assistant Operating System is new enough.""" - # Only warn if a USB adapter is plugged in - if not any( - entry - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.source != SOURCE_IGNORE - ): - return True - if ( - not hass.components.hassio.is_hassio() - or not (os_info := hass.components.hassio.get_os_info()) - or not (haos_version := os_info.get("version")) - or AwesomeVersion(haos_version) >= RECOMMENDED_MIN_HAOS_VERSION - ): - return True - return False - - -@hass_callback -def _async_check_haos(hass: HomeAssistant) -> None: - """Create or delete an the haos_outdated issue.""" - if _async_haos_is_new_enough(hass): - async_delete_issue(hass, DOMAIN, "haos_outdated") - return - async_create_issue( - hass, - DOMAIN, - "haos_outdated", - is_fixable=False, - severity=IssueSeverity.WARNING, - learn_more_url="/config/updates", - translation_key="haos_outdated", - ) - - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the bluetooth integration.""" integration_matcher = IntegrationMatcher(await async_get_bluetooth(hass)) @@ -242,12 +194,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: EVENT_HOMEASSISTANT_STOP, hass_callback(lambda event: cancel()) ) - # Wait to check until after start to make sure - # that the system info is available. - hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STARTED, - hass_callback(lambda event: _async_check_haos(hass)), - ) + async_delete_issue(hass, DOMAIN, "haos_outdated") return True diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index e7f95a2b63a3..8d936b7286ff 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -1,7 +1,6 @@ { "domain": "bluetooth", "name": "Bluetooth", - "after_dependencies": ["hassio"], "codeowners": ["@bdraco"], "config_flow": true, "dependencies": ["logger", "usb"], diff --git a/homeassistant/components/bluetooth/strings.json b/homeassistant/components/bluetooth/strings.json index a988477778d9..cae88ef24c19 100644 --- a/homeassistant/components/bluetooth/strings.json +++ b/homeassistant/components/bluetooth/strings.json @@ -1,10 +1,4 @@ { - "issues": { - "haos_outdated": { - "title": "Update to Home Assistant Operating System 9.0 or later", - "description": "To improve Bluetooth reliability and performance, we highly recommend you update to version 9.0 or later of the Home Assistant Operating System." - } - }, "config": { "flow_title": "{name}", "step": { diff --git a/tests/components/bluetooth/test_init.py b/tests/components/bluetooth/test_init.py index d9901a68b0fd..24f1039175b8 100644 --- a/tests/components/bluetooth/test_init.py +++ b/tests/components/bluetooth/test_init.py @@ -8,7 +8,6 @@ from bleak import BleakError from bleak.backends.scanner import AdvertisementData, BLEDevice from bluetooth_adapters import DEFAULT_ADDRESS import pytest -from syrupy.assertion import SnapshotAssertion from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( @@ -2922,35 +2921,13 @@ async def test_discover_new_usb_adapters_with_firmware_fallback_delay( assert len(hass.config_entries.flow.async_progress(DOMAIN)) == 1 -async def test_issue_outdated_haos( - hass: HomeAssistant, - mock_bleak_scanner_start: MagicMock, - one_adapter: None, - operating_system_85: None, - snapshot: SnapshotAssertion, -) -> None: - """Test we create an issue on outdated haos.""" - entry = MockConfigEntry( - domain=bluetooth.DOMAIN, data={}, unique_id="00:00:00:00:00:01" - ) - entry.add_to_hass(hass) - assert await async_setup_component(hass, bluetooth.DOMAIN, {}) - await hass.async_block_till_done() - hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) - await hass.async_block_till_done() - registry = async_get_issue_registry(hass) - issue = registry.async_get_issue(DOMAIN, "haos_outdated") - assert issue is not None - assert issue == snapshot - - -async def test_issue_outdated_haos_no_adapters( +async def test_issue_outdated_haos_removed( hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, no_adapters: None, operating_system_85: None, ) -> None: - """Test we do not create an issue on outdated haos if there are no adapters.""" + """Test we do not create an issue on outdated haos anymore.""" assert await async_setup_component(hass, bluetooth.DOMAIN, {}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) From 89cc57c0d6b0e395ee893fe886cef603513d13be Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 30 May 2023 12:43:48 -0500 Subject: [PATCH 696/822] Fix double state write on camera added to hass when rtsp_to_webrtc is present (#93806) --- homeassistant/components/camera/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index c09586848dff..b22e2996f7e4 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -673,7 +673,10 @@ class Camera(Entity): async def async_internal_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_internal_added_to_hass() - await self.async_refresh_providers() + # Avoid calling async_refresh_providers() in here because it + # it will write state a second time since state is always + # written when an entity is added to hass. + self._rtsp_to_webrtc = await self._async_use_rtsp_to_webrtc() async def async_refresh_providers(self) -> None: """Determine if any of the registered providers are suitable for this entity. From 46d888502375fa2fdf4e03b284a25b95e91b531e Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 30 May 2023 19:43:59 +0200 Subject: [PATCH 697/822] Use deployment environment in GitHub build actions (#93800) --- .github/workflows/builder.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index ae0cfb518632..ae650d3052a0 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -62,6 +62,7 @@ jobs: build_python: name: Build PyPi package + environment: ${{ needs.init.outputs.channel }} needs: ["init", "build_base"] runs-on: ubuntu-latest if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true' @@ -299,6 +300,7 @@ jobs: publish_ha: name: Publish version files + environment: ${{ needs.init.outputs.channel }} if: github.repository_owner == 'home-assistant' needs: ["init", "build_machine"] runs-on: ubuntu-latest @@ -332,6 +334,7 @@ jobs: publish_container: name: Publish meta container for ${{ matrix.registry }} + environment: ${{ needs.init.outputs.channel }} if: github.repository_owner == 'home-assistant' needs: ["init", "build_base"] runs-on: ubuntu-latest From 1e0770ff8a058875b7f85fe38021c456a431186a Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 30 May 2023 19:48:47 +0200 Subject: [PATCH 698/822] Add ssl_verify option to imap integration (#93811) * Add ssl_verify option to imap integration * Add test --- homeassistant/components/imap/config_flow.py | 11 +++- homeassistant/components/imap/coordinator.py | 15 ++++-- homeassistant/components/imap/strings.json | 3 +- tests/components/imap/test_config_flow.py | 54 ++++++++++++++++++-- tests/components/imap/test_init.py | 18 +++++-- 5 files changed, 89 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/imap/config_flow.py b/homeassistant/components/imap/config_flow.py index 4f8f8090aca2..6a737df74760 100644 --- a/homeassistant/components/imap/config_flow.py +++ b/homeassistant/components/imap/config_flow.py @@ -10,12 +10,19 @@ 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 +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.exceptions import TemplateError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.selector import ( + BooleanSelector, SelectSelector, SelectSelectorConfig, SelectSelectorMode, @@ -42,6 +49,7 @@ from .const import ( from .coordinator import connect_to_server from .errors import InvalidAuth, InvalidFolder +BOOLEAN_SELECTOR = BooleanSelector() CIPHER_SELECTOR = SelectSelector( SelectSelectorConfig( options=list(SSLCipherList), @@ -68,6 +76,7 @@ CONFIG_SCHEMA_ADVANCED = { vol.Optional( CONF_SSL_CIPHER_LIST, default=SSLCipherList.PYTHON_DEFAULT ): CIPHER_SELECTOR, + vol.Optional(CONF_VERIFY_SSL, default=True): BOOLEAN_SELECTOR, } OPTIONS_SCHEMA = vol.Schema( diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index fc6b0c28114a..d41aaf8c4975 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -18,6 +18,7 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_PORT, CONF_USERNAME, + CONF_VERIFY_SSL, CONTENT_TYPE_TEXT_PLAIN, ) from homeassistant.core import HomeAssistant @@ -29,7 +30,11 @@ from homeassistant.exceptions import ( from homeassistant.helpers.json import json_bytes from homeassistant.helpers.template import Template from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util.ssl import SSLCipherList, client_context +from homeassistant.util.ssl import ( + SSLCipherList, + client_context, + create_no_verify_ssl_context, +) from .const import ( CONF_CHARSET, @@ -54,9 +59,11 @@ MAX_EVENT_DATA_BYTES = 32168 async def connect_to_server(data: Mapping[str, Any]) -> IMAP4_SSL: """Connect to imap server and return client.""" - ssl_context = client_context( - ssl_cipher_list=data.get(CONF_SSL_CIPHER_LIST, SSLCipherList.PYTHON_DEFAULT) - ) + ssl_cipher_list: str = data.get(CONF_SSL_CIPHER_LIST, SSLCipherList.PYTHON_DEFAULT) + if data.get(CONF_VERIFY_SSL, True): + ssl_context = client_context(ssl_cipher_list=ssl_cipher_list) + else: + ssl_context = create_no_verify_ssl_context() client = IMAP4_SSL(data[CONF_SERVER], data[CONF_PORT], ssl_context=ssl_context) await client.wait_hello_from_server() diff --git a/homeassistant/components/imap/strings.json b/homeassistant/components/imap/strings.json index b640b3f75152..1e237f72b442 100644 --- a/homeassistant/components/imap/strings.json +++ b/homeassistant/components/imap/strings.json @@ -10,7 +10,8 @@ "charset": "Character set", "folder": "Folder", "search": "IMAP search", - "ssl_cipher_list": "SSL cipher list (Advanced)" + "ssl_cipher_list": "SSL cipher list (Advanced)", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" } }, "reauth_confirm": { diff --git a/tests/components/imap/test_config_flow.py b/tests/components/imap/test_config_flow.py index d0ab4822b67a..fb4347b08a7a 100644 --- a/tests/components/imap/test_config_flow.py +++ b/tests/components/imap/test_config_flow.py @@ -533,12 +533,14 @@ async def test_import_flow_connection_error(hass: HomeAssistant) -> None: @pytest.mark.parametrize("cipher_list", ["python_default", "modern", "intermediate"]) -async def test_config_flow_with_cipherlist( - hass: HomeAssistant, mock_setup_entry: AsyncMock, cipher_list: str +@pytest.mark.parametrize("verify_ssl", [False, True]) +async def test_config_flow_with_cipherlist_and_ssl_verify( + hass: HomeAssistant, mock_setup_entry: AsyncMock, cipher_list: str, verify_ssl: True ) -> None: - """Test with alternate cipherlist.""" + """Test with alternate cipherlist or disabled ssl verification.""" config = MOCK_CONFIG.copy() config["ssl_cipher_list"] = cipher_list + config["verify_ssl"] = verify_ssl result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER, "show_advanced_options": True}, @@ -562,3 +564,49 @@ async def test_config_flow_with_cipherlist( assert result2["title"] == "email@email.com" assert result2["data"] == config assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_config_flow_from_with_advanced_settings( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test if advanced settings show correctly.""" + config = MOCK_CONFIG.copy() + config["ssl_cipher_list"] = "python_default" + config["verify_ssl"] = True + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER, "show_advanced_options": True}, + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] is None + + with patch( + "homeassistant.components.imap.config_flow.connect_to_server", + side_effect=asyncio.TimeoutError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], config + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"]["base"] == "cannot_connect" + assert "ssl_cipher_list" in result2["data_schema"].schema + + config["ssl_cipher_list"] = "modern" + with patch( + "homeassistant.components.imap.config_flow.connect_to_server" + ) as mock_client: + mock_client.return_value.search.return_value = ( + "OK", + [b""], + ) + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], config + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == "email@email.com" + assert result3["data"] == config + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/imap/test_init.py b/tests/components/imap/test_init.py index 2fc5a56a0a05..712f159b4cb9 100644 --- a/tests/components/imap/test_init.py +++ b/tests/components/imap/test_init.py @@ -34,16 +34,28 @@ from tests.common import MockConfigEntry, async_capture_events, async_fire_time_ @pytest.mark.parametrize( - "cipher_list", [None, "python_default", "modern", "intermediate"] + ("cipher_list", "verify_ssl"), + [ + (None, None), + ("python_default", True), + ("python_default", False), + ("modern", True), + ("intermediate", True), + ], ) @pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) async def test_entry_startup_and_unload( - hass: HomeAssistant, mock_imap_protocol: MagicMock, cipher_list: str + hass: HomeAssistant, + mock_imap_protocol: MagicMock, + cipher_list: str | None, + verify_ssl: bool | None, ) -> None: """Test imap entry startup and unload with push and polling coordinator and alternate ciphers.""" config = MOCK_CONFIG.copy() - if cipher_list: + if cipher_list is not None: config["ssl_cipher_list"] = cipher_list + if verify_ssl is not None: + config["verify_ssl"] = verify_ssl config_entry = MockConfigEntry(domain=DOMAIN, data=config) config_entry.add_to_hass(hass) From 05c3d8bb372c334f6910f84fd70655737e0edef4 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Tue, 30 May 2023 14:44:32 -0400 Subject: [PATCH 699/822] Add zwave_js node statistics sensors (#91714) * Add node statistics sensors * fix tests and don't let controller state leak across tests * Add background RSSI * Remove extra logging statement * fix test * comments * setup platform once * Add static properties to entity description * Update homeassistant/components/zwave_js/sensor.py Co-authored-by: Martin Hjelmare * don't dupe attribute values in entity description * fix exception --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/zwave_js/__init__.py | 11 +- homeassistant/components/zwave_js/sensor.py | 248 ++++++++++++++++++ tests/components/zwave_js/conftest.py | 4 +- .../zwave_js/fixtures/zp3111-5_state.json | 4 +- tests/components/zwave_js/test_init.py | 4 +- tests/components/zwave_js/test_sensor.py | 165 ++++++++++++ 6 files changed, 430 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index 8d118fd89f55..a89d20d8384a 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -321,6 +321,9 @@ class ControllerEvents: async def async_on_node_added(self, node: ZwaveNode) -> None: """Handle node added event.""" + # Every node including the controller will have at least one sensor + await self.driver_events.async_setup_platform(Platform.SENSOR) + # Remove stale entities that may exist from a previous interview when an # interview is started. base_unique_id = get_valueless_base_unique_id(self.driver_events.driver, node) @@ -337,7 +340,6 @@ class ControllerEvents: # No need for a ping button or node status sensor for controller nodes if not node.is_controller_node: # Create a node status sensor for each device - await self.driver_events.async_setup_platform(Platform.SENSOR) async_dispatcher_send( self.hass, f"{DOMAIN}_{self.config_entry.entry_id}_add_node_status_sensor", @@ -352,6 +354,13 @@ class ControllerEvents: node, ) + # Create statistics sensors for each device + async_dispatcher_send( + self.hass, + f"{DOMAIN}_{self.config_entry.entry_id}_add_statistics_sensors", + node, + ) + LOGGER.debug("Node added: %s", node.node_id) # Listen for ready node events, both new and re-interview. diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 1a292cb17ef4..074bfa212dbe 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -11,8 +11,11 @@ from zwave_js_server.const.command_class.meter import ( RESET_METER_OPTION_TARGET_VALUE, RESET_METER_OPTION_TYPE, ) +from zwave_js_server.model.controller import Controller +from zwave_js_server.model.controller.statistics import ControllerStatisticsDataType 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.util.command_class.meter import get_meter_type @@ -36,6 +39,7 @@ from homeassistant.const import ( UnitOfPower, UnitOfPressure, UnitOfTemperature, + UnitOfTime, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_platform @@ -272,6 +276,134 @@ ENTITY_DESCRIPTION_KEY_MAP = { } +# Controller statistics descriptions +ENTITY_DESCRIPTION_CONTROLLER_STATISTICS_LIST = [ + SensorEntityDescription( + "messagesTX", + name="Successful messages (TX)", + state_class=SensorStateClass.TOTAL, + ), + SensorEntityDescription( + "messagesRX", + name="Successful messages (RX)", + state_class=SensorStateClass.TOTAL, + ), + SensorEntityDescription( + "messagesDroppedTX", + name="Messages dropped (TX)", + state_class=SensorStateClass.TOTAL, + ), + SensorEntityDescription( + "messagesDroppedRX", + name="Messages dropped (RX)", + state_class=SensorStateClass.TOTAL, + ), + SensorEntityDescription( + "NAK", + name="Messages not accepted", + state_class=SensorStateClass.TOTAL, + ), + SensorEntityDescription( + "CAN", name="Collisions", state_class=SensorStateClass.TOTAL + ), + SensorEntityDescription( + "timeoutACK", name="Missing ACKs", state_class=SensorStateClass.TOTAL + ), + SensorEntityDescription( + "timeoutResponse", + name="Timed out responses", + state_class=SensorStateClass.TOTAL, + ), + SensorEntityDescription( + "timeoutCallback", + name="Timed out callbacks", + state_class=SensorStateClass.TOTAL, + ), + SensorEntityDescription( + "backgroundRSSI.channel0.average", + name="Average background RSSI (channel 0)", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + ), + SensorEntityDescription( + "backgroundRSSI.channel0.current", + name="Current background RSSI (channel 0)", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + "backgroundRSSI.channel1.average", + name="Average background RSSI (channel 1)", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + ), + SensorEntityDescription( + "backgroundRSSI.channel1.current", + name="Current background RSSI (channel 1)", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + "backgroundRSSI.channel2.average", + name="Average background RSSI (channel 2)", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + ), + SensorEntityDescription( + "backgroundRSSI.channel2.current", + name="Current background RSSI (channel 2)", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + state_class=SensorStateClass.MEASUREMENT, + ), +] + +# Node statistics descriptions +ENTITY_DESCRIPTION_NODE_STATISTICS_LIST = [ + SensorEntityDescription( + "commandsRX", + name="Successful commands (RX)", + state_class=SensorStateClass.TOTAL, + ), + SensorEntityDescription( + "commandsTX", + name="Successful commands (TX)", + state_class=SensorStateClass.TOTAL, + ), + SensorEntityDescription( + "commandsDroppedRX", + name="Commands dropped (RX)", + state_class=SensorStateClass.TOTAL, + ), + SensorEntityDescription( + "commandsDroppedTX", + name="Commands dropped (TX)", + state_class=SensorStateClass.TOTAL, + ), + SensorEntityDescription( + "timeoutResponse", + name="Timed out responses", + state_class=SensorStateClass.TOTAL, + ), + SensorEntityDescription( + "rtt", + name="Round Trip Time", + native_unit_of_measurement=UnitOfTime.MILLISECONDS, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + "rssi", + name="RSSI", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + state_class=SensorStateClass.MEASUREMENT, + ), +] + + def get_entity_description( data: NumericSensorDataTemplateData, ) -> SensorEntityDescription: @@ -347,6 +479,27 @@ async def async_setup_entry( assert driver is not None # Driver is ready before platforms are loaded. async_add_entities([ZWaveNodeStatusSensor(config_entry, driver, node)]) + @callback + def async_add_statistics_sensors(node: ZwaveNode) -> None: + """Add statistics sensors.""" + driver = client.driver + assert driver is not None # Driver is ready before platforms are loaded. + async_add_entities( + [ + ZWaveStatisticsSensor( + config_entry, + driver, + driver.controller if driver.controller.own_node == node else node, + entity_description, + ) + for entity_description in ( + ENTITY_DESCRIPTION_CONTROLLER_STATISTICS_LIST + if driver.controller.own_node == node + else ENTITY_DESCRIPTION_NODE_STATISTICS_LIST + ) + ] + ) + config_entry.async_on_unload( async_dispatcher_connect( hass, @@ -363,6 +516,14 @@ async def async_setup_entry( ) ) + config_entry.async_on_unload( + async_dispatcher_connect( + hass, + f"{DOMAIN}_{config_entry.entry_id}_add_statistics_sensors", + async_add_statistics_sensors, + ) + ) + platform = entity_platform.async_get_current_platform() platform.async_register_entity_service( SERVICE_RESET_METER, @@ -625,3 +786,90 @@ class ZWaveNodeStatusSensor(SensorEntity): ) self._attr_native_value: str = self.node.status.name.lower() self.async_write_ha_state() + + +class ZWaveStatisticsSensor(SensorEntity): + """Representation of a node/controller statistics sensor.""" + + _attr_should_poll = False + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_entity_registry_enabled_default = False + _attr_has_entity_name = True + + def __init__( + self, + config_entry: ConfigEntry, + driver: Driver, + statistics_src: ZwaveNode | Controller, + description: SensorEntityDescription, + ) -> None: + """Initialize a Z-Wave statistics entity.""" + self.entity_description = description + self.config_entry = config_entry + self.statistics_src = statistics_src + node = ( + statistics_src.own_node + if isinstance(statistics_src, Controller) + else statistics_src + ) + assert node + + # Entity class attributes + self._base_unique_id = get_valueless_base_unique_id(driver, node) + self._attr_unique_id = f"{self._base_unique_id}.statistics_{description.key}" + # device may not be precreated in main handler yet + self._attr_device_info = get_device_info(driver, node) + + async def async_poll_value(self, _: bool) -> None: + """Poll a value.""" + raise ValueError( + "There is no value to refresh for this entity so the zwave_js.refresh_value" + " service won't work for it" + ) + + def _get_data_from_statistics( + self, statistics: ControllerStatisticsDataType | NodeStatisticsDataType + ) -> int | None: + """Get the data from the statistics dict.""" + if "." not in self.entity_description.key: + return cast(int | None, statistics.get(self.entity_description.key)) + + # If key contains dots, we need to traverse the dict to get to the right value + for key in self.entity_description.key.split("."): + if key not in statistics: + return None + statistics = statistics[key] # type: ignore[literal-required] + return cast(int, statistics) + + @callback + def statistics_updated(self, event_data: dict) -> None: + """Call when statistics updated event is received.""" + self._attr_native_value = self._get_data_from_statistics( + event_data["statistics"] + ) + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Call when entity is added.""" + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{DOMAIN}_{self.unique_id}_poll_value", + self.async_poll_value, + ) + ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{DOMAIN}_{self._base_unique_id}_remove_entity", + self.async_remove, + ) + ) + self.async_on_remove( + self.statistics_src.on("statistics updated", self.statistics_updated) + ) + + # Set initial state + self._attr_native_value = self._get_data_from_statistics( + self.statistics_src.statistics.data + ) diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index 3191bbfcea50..af23fbef32ee 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -653,7 +653,9 @@ def mock_client_fixture( client.connect = AsyncMock(side_effect=connect) client.listen = AsyncMock(side_effect=listen) client.disconnect = AsyncMock(side_effect=disconnect) - client.driver = Driver(client, controller_state, log_config_state) + client.driver = Driver( + client, copy.deepcopy(controller_state), copy.deepcopy(log_config_state) + ) node = Node(client, copy.deepcopy(controller_node_state)) client.driver.controller.nodes[node.node_id] = node diff --git a/tests/components/zwave_js/fixtures/zp3111-5_state.json b/tests/components/zwave_js/fixtures/zp3111-5_state.json index 54f37d389ddb..68bb0f03af8d 100644 --- a/tests/components/zwave_js/fixtures/zp3111-5_state.json +++ b/tests/components/zwave_js/fixtures/zp3111-5_state.json @@ -690,8 +690,8 @@ "interviewStage": "Complete", "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0109:0x2021:0x2101:5.1", "statistics": { - "commandsTX": 39, - "commandsRX": 38, + "commandsTX": 0, + "commandsRX": 0, "commandsDroppedRX": 0, "commandsDroppedTX": 0, "timeoutResponse": 0 diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 2b1e16a6c120..a33ee75661ca 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -963,7 +963,7 @@ async def test_removed_device( # Check how many entities there are ent_reg = er.async_get(hass) entity_entries = er.async_entries_for_config_entry(ent_reg, integration.entry_id) - assert len(entity_entries) == 62 + assert len(entity_entries) == 91 # Remove a node and reload the entry old_node = driver.controller.nodes.pop(13) @@ -975,7 +975,7 @@ async def test_removed_device( device_entries = dr.async_entries_for_config_entry(dev_reg, integration.entry_id) assert len(device_entries) == 2 entity_entries = er.async_entries_for_config_entry(ent_reg, integration.entry_id) - assert len(entity_entries) == 38 + assert len(entity_entries) == 60 assert dev_reg.async_get_device({get_device_id(driver, old_node)}) is None diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index 783dd14f2347..46fe7ee8df32 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -565,3 +565,168 @@ async def test_unit_change(hass: HomeAssistant, zp3111, client, integration) -> assert state.state == "100.0" assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfTemperature.CELSIUS assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.TEMPERATURE + + +CONTROLLER_STATISTICS_ENTITY_PREFIX = "sensor.z_stick_gen5_usb_controller_" +# controller statistics with initial state of 0 +CONTROLLER_STATISTICS_SUFFIXES = { + "successful_messages_tx": 1, + "successful_messages_rx": 2, + "messages_dropped_tx": 3, + "messages_dropped_rx": 4, + "messages_not_accepted": 5, + "collisions": 6, + "missing_acks": 7, + "timed_out_responses": 8, + "timed_out_callbacks": 9, +} +# controller statistics with initial state of unknown +CONTROLLER_STATISTICS_SUFFIXES_UNKNOWN = { + "current_background_rssi_channel_0": -1, + "average_background_rssi_channel_0": -2, + "current_background_rssi_channel_1": -3, + "average_background_rssi_channel_1": -4, + "current_background_rssi_channel_2": STATE_UNKNOWN, + "average_background_rssi_channel_2": STATE_UNKNOWN, +} +NODE_STATISTICS_ENTITY_PREFIX = "sensor.4_in_1_sensor_" +# node statistics with initial state of 0 +NODE_STATISTICS_SUFFIXES = { + "successful_commands_tx": 1, + "successful_commands_rx": 2, + "commands_dropped_tx": 3, + "commands_dropped_rx": 4, + "timed_out_responses": 5, +} +# node statistics with initial state of unknown +NODE_STATISTICS_SUFFIXES_UNKNOWN = { + "round_trip_time": 6, + "rssi": 7, +} + + +async def test_statistics_sensors( + hass: HomeAssistant, zp3111, client, integration +) -> None: + """Test statistics sensors.""" + ent_reg = er.async_get(hass) + + for prefix, suffixes in ( + (CONTROLLER_STATISTICS_ENTITY_PREFIX, CONTROLLER_STATISTICS_SUFFIXES), + (CONTROLLER_STATISTICS_ENTITY_PREFIX, CONTROLLER_STATISTICS_SUFFIXES_UNKNOWN), + (NODE_STATISTICS_ENTITY_PREFIX, NODE_STATISTICS_SUFFIXES), + (NODE_STATISTICS_ENTITY_PREFIX, NODE_STATISTICS_SUFFIXES_UNKNOWN), + ): + for suffix_key in suffixes: + entry = ent_reg.async_get(f"{prefix}{suffix_key}") + assert entry + assert entry.disabled + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + ent_reg.async_update_entity(entry.entity_id, **{"disabled_by": None}) + + # reload integration and check if entity is correctly there + await hass.config_entries.async_reload(integration.entry_id) + await hass.async_block_till_done() + + for prefix, suffixes, initial_state in ( + (CONTROLLER_STATISTICS_ENTITY_PREFIX, CONTROLLER_STATISTICS_SUFFIXES, "0"), + ( + CONTROLLER_STATISTICS_ENTITY_PREFIX, + CONTROLLER_STATISTICS_SUFFIXES_UNKNOWN, + STATE_UNKNOWN, + ), + (NODE_STATISTICS_ENTITY_PREFIX, NODE_STATISTICS_SUFFIXES, "0"), + ( + NODE_STATISTICS_ENTITY_PREFIX, + NODE_STATISTICS_SUFFIXES_UNKNOWN, + STATE_UNKNOWN, + ), + ): + for suffix_key in suffixes: + entry = ent_reg.async_get(f"{prefix}{suffix_key}") + assert entry + assert not entry.disabled + assert entry.disabled_by is None + + state = hass.states.get(entry.entity_id) + assert state + assert state.state == initial_state + + # Fire statistics updated for controller + event = Event( + "statistics updated", + { + "source": "controller", + "event": "statistics updated", + "statistics": { + "messagesTX": 1, + "messagesRX": 2, + "messagesDroppedTX": 3, + "messagesDroppedRX": 4, + "NAK": 5, + "CAN": 6, + "timeoutACK": 7, + "timeoutResponse": 8, + "timeoutCallback": 9, + "backgroundRSSI": { + "channel0": { + "current": -1, + "average": -2, + }, + "channel1": { + "current": -3, + "average": -4, + }, + "timestamp": 1681967176510, + }, + }, + }, + ) + client.driver.controller.receive_event(event) + + # Fire statistics updated event for node + event = Event( + "statistics updated", + { + "source": "node", + "event": "statistics updated", + "nodeId": zp3111.node_id, + "statistics": { + "commandsTX": 1, + "commandsRX": 2, + "commandsDroppedTX": 3, + "commandsDroppedRX": 4, + "timeoutResponse": 5, + "rtt": 6, + "rssi": 7, + "lwr": { + "protocolDataRate": 1, + "rssi": 1, + "repeaters": [], + "repeaterRSSI": [], + "routeFailedBetween": [], + }, + "nlwr": { + "protocolDataRate": 2, + "rssi": 2, + "repeaters": [], + "repeaterRSSI": [], + "routeFailedBetween": [], + }, + }, + }, + ) + zp3111.receive_event(event) + + # Check that states match the statistics from the updates + for prefix, suffixes in ( + (CONTROLLER_STATISTICS_ENTITY_PREFIX, CONTROLLER_STATISTICS_SUFFIXES), + (CONTROLLER_STATISTICS_ENTITY_PREFIX, CONTROLLER_STATISTICS_SUFFIXES_UNKNOWN), + (NODE_STATISTICS_ENTITY_PREFIX, NODE_STATISTICS_SUFFIXES), + (NODE_STATISTICS_ENTITY_PREFIX, NODE_STATISTICS_SUFFIXES_UNKNOWN), + ): + for suffix_key, val in suffixes.items(): + state = hass.states.get(f"{prefix}{suffix_key}") + assert state + assert state.state == str(val) From c25b26214b4205ebe833e21a9dfbde1e6cd9c3a8 Mon Sep 17 00:00:00 2001 From: Mike Degatano Date: Tue, 30 May 2023 16:08:45 -0400 Subject: [PATCH 700/822] Add docker config repair for supervisor issue (#93820) --- homeassistant/components/hassio/__init__.py | 14 +- homeassistant/components/hassio/const.py | 21 ++- homeassistant/components/hassio/issues.py | 46 +++--- homeassistant/components/hassio/repairs.py | 55 ++++++- homeassistant/components/hassio/strings.json | 19 ++- tests/components/hassio/conftest.py | 132 ++++++++++++++++- tests/components/hassio/test_issues.py | 2 +- tests/components/hassio/test_repairs.py | 147 ++++++++++++++++--- 8 files changed, 376 insertions(+), 60 deletions(-) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 4091fa1d984f..2ae4faa7878f 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -299,7 +299,7 @@ def get_supervisor_info(hass: HomeAssistant) -> dict[str, Any] | None: @callback @bind_hass -def get_addons_info(hass): +def get_addons_info(hass: HomeAssistant) -> dict[str, dict[str, Any]] | None: """Return Addons info. Async friendly. @@ -367,6 +367,16 @@ def get_core_info(hass: HomeAssistant) -> dict[str, Any] | None: return hass.data.get(DATA_CORE_INFO) +@callback +@bind_hass +def get_issues_info(hass: HomeAssistant) -> SupervisorIssues | None: + """Return Supervisor issues info. + + Async friendly. + """ + return hass.data.get(DATA_KEY_SUPERVISOR_ISSUES) + + @callback @bind_hass def is_hassio(hass: HomeAssistant) -> bool: @@ -778,7 +788,7 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): new_data: dict[str, Any] = {} supervisor_info = get_supervisor_info(self.hass) or {} - addons_info = get_addons_info(self.hass) + addons_info = get_addons_info(self.hass) or {} addons_stats = get_addons_stats(self.hass) addons_changelogs = get_addons_changelogs(self.hass) store_data = get_store(self.hass) or {} diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index edd7086fd80f..2bc314f169a4 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -1,5 +1,5 @@ """Hass.io const variables.""" -from enum import Enum +from homeassistant.backports.enum import StrEnum DOMAIN = "hassio" @@ -77,9 +77,12 @@ DATA_KEY_HOST = "host" DATA_KEY_SUPERVISOR_ISSUES = "supervisor_issues" PLACEHOLDER_KEY_REFERENCE = "reference" +PLACEHOLDER_KEY_COMPONENTS = "components" + +ISSUE_KEY_SYSTEM_DOCKER_CONFIG = "issue_system_docker_config" -class SupervisorEntityModel(str, Enum): +class SupervisorEntityModel(StrEnum): """Supervisor entity model.""" ADDON = "Home Assistant Add-on" @@ -87,3 +90,17 @@ class SupervisorEntityModel(str, Enum): CORE = "Home Assistant Core" SUPERVIOSR = "Home Assistant Supervisor" HOST = "Home Assistant Host" + + +class SupervisorIssueContext(StrEnum): + """Context for supervisor issues.""" + + ADDON = "addon" + CORE = "core" + DNS_SERVER = "dns_server" + MOUNT = "mount" + OS = "os" + PLUGIN = "plugin" + SUPERVISOR = "supervisor" + STORE = "store" + SYSTEM = "system" diff --git a/homeassistant/components/hassio/issues.py b/homeassistant/components/hassio/issues.py index 314b01ec388f..a92fc392fa44 100644 --- a/homeassistant/components/hassio/issues.py +++ b/homeassistant/components/hassio/issues.py @@ -35,8 +35,10 @@ from .const import ( EVENT_SUPERVISOR_EVENT, EVENT_SUPERVISOR_UPDATE, EVENT_SUPPORTED_CHANGED, + ISSUE_KEY_SYSTEM_DOCKER_CONFIG, PLACEHOLDER_KEY_REFERENCE, UPDATE_KEY_SUPERVISOR, + SupervisorIssueContext, ) from .handler import HassIO, HassioAPIError @@ -88,6 +90,7 @@ ISSUE_KEYS_FOR_REPAIRS = { "issue_mount_mount_failed", "issue_system_multiple_data_disks", "issue_system_reboot_required", + ISSUE_KEY_SYSTEM_DOCKER_CONFIG, } _LOGGER = logging.getLogger(__name__) @@ -107,22 +110,22 @@ class Suggestion: """Suggestion from Supervisor which resolves an issue.""" uuid: str - type_: str - context: str + type: str + context: SupervisorIssueContext reference: str | None = None @property def key(self) -> str: """Get key for suggestion (combination of context and type).""" - return f"{self.context}_{self.type_}" + return f"{self.context}_{self.type}" @classmethod def from_dict(cls, data: SuggestionDataType) -> Suggestion: """Convert from dictionary representation.""" return cls( uuid=data["uuid"], - type_=data["type"], - context=data["context"], + type=data["type"], + context=SupervisorIssueContext(data["context"]), reference=data["reference"], ) @@ -142,15 +145,15 @@ class Issue: """Issue from Supervisor.""" uuid: str - type_: str - context: str + type: str + context: SupervisorIssueContext reference: str | None = None suggestions: list[Suggestion] = field(default_factory=list, compare=False) @property def key(self) -> str: """Get key for issue (combination of context and type).""" - return f"issue_{self.context}_{self.type_}" + return f"issue_{self.context}_{self.type}" @classmethod def from_dict(cls, data: IssueDataType) -> Issue: @@ -158,8 +161,8 @@ class Issue: suggestions: list[SuggestionDataType] = data.get("suggestions", []) return cls( uuid=data["uuid"], - type_=data["type"], - context=data["context"], + type=data["type"], + context=SupervisorIssueContext(data["context"]), reference=data["reference"], suggestions=[ Suggestion.from_dict(suggestion) for suggestion in suggestions @@ -242,6 +245,11 @@ class SupervisorIssues: self._unsupported_reasons = reasons + @property + def issues(self) -> set[Issue]: + """Get issues.""" + return set(self._issues.values()) + def add_issue(self, issue: Issue) -> None: """Add or update an issue in the list. Create or update a repair if necessary.""" if issue.key in ISSUE_KEYS_FOR_REPAIRS: @@ -263,20 +271,10 @@ class SupervisorIssues: async def add_issue_from_data(self, data: IssueDataType) -> None: """Add issue from data to list after getting latest suggestions.""" try: - suggestions = (await self._client.get_suggestions_for_issue(data["uuid"]))[ - ATTR_SUGGESTIONS - ] - self.add_issue( - Issue( - uuid=data["uuid"], - type_=data["type"], - context=data["context"], - reference=data["reference"], - suggestions=[ - Suggestion.from_dict(suggestion) for suggestion in suggestions - ], - ) - ) + data["suggestions"] = ( + await self._client.get_suggestions_for_issue(data["uuid"]) + )[ATTR_SUGGESTIONS] + self.add_issue(Issue.from_dict(data)) except HassioAPIError: _LOGGER.error( "Could not get suggestions for supervisor issue %s, skipping it", diff --git a/homeassistant/components/hassio/repairs.py b/homeassistant/components/hassio/repairs.py index 5874c99f13d2..d5e26d4670f4 100644 --- a/homeassistant/components/hassio/repairs.py +++ b/homeassistant/components/hassio/repairs.py @@ -10,9 +10,15 @@ from homeassistant.components.repairs import RepairsFlow from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult -from .const import DATA_KEY_SUPERVISOR_ISSUES, PLACEHOLDER_KEY_REFERENCE +from . import get_addons_info, get_issues_info +from .const import ( + ISSUE_KEY_SYSTEM_DOCKER_CONFIG, + PLACEHOLDER_KEY_COMPONENTS, + PLACEHOLDER_KEY_REFERENCE, + SupervisorIssueContext, +) from .handler import HassioAPIError, async_apply_suggestion -from .issues import Issue, Suggestion, SupervisorIssues +from .issues import Issue, Suggestion SUGGESTION_CONFIRMATION_REQUIRED = {"system_execute_reboot"} @@ -37,10 +43,8 @@ class SupervisorIssueRepairFlow(RepairsFlow): @property def issue(self) -> Issue | None: """Get associated issue.""" - if not self._issue: - supervisor_issues: SupervisorIssues = self.hass.data[ - DATA_KEY_SUPERVISOR_ISSUES - ] + supervisor_issues = get_issues_info(self.hass) + if not self._issue and supervisor_issues: self._issue = supervisor_issues.get_issue(self._issue_id) return self._issue @@ -121,10 +125,49 @@ class SupervisorIssueRepairFlow(RepairsFlow): return _async_step +class DockerConfigIssueRepairFlow(SupervisorIssueRepairFlow): + """Handler for docker config issue fixing flow.""" + + @property + def description_placeholders(self) -> dict[str, str] | None: + """Get description placeholders for steps.""" + placeholders = {PLACEHOLDER_KEY_COMPONENTS: ""} + supervisor_issues = get_issues_info(self.hass) + if supervisor_issues and self.issue: + addons = get_addons_info(self.hass) or {} + components: list[str] = [] + for issue in supervisor_issues.issues: + if issue.key == self.issue.key or issue.type != self.issue.type: + continue + + if issue.context == SupervisorIssueContext.CORE: + components.insert(0, "Home Assistant") + elif issue.context == SupervisorIssueContext.ADDON: + components.append( + next( + ( + info["name"] + for slug, info in addons.items() + if slug == issue.reference + ), + issue.reference or "", + ) + ) + + placeholders[PLACEHOLDER_KEY_COMPONENTS] = "\n- ".join(components) + + return placeholders + + async def async_create_fix_flow( hass: HomeAssistant, issue_id: str, data: dict[str, str | int | float | None] | None, ) -> RepairsFlow: """Create flow.""" + supervisor_issues = get_issues_info(hass) + issue = supervisor_issues and supervisor_issues.get_issue(issue_id) + if issue and issue.key == ISSUE_KEY_SYSTEM_DOCKER_CONFIG: + return DockerConfigIssueRepairFlow(issue_id) + return SupervisorIssueRepairFlow(issue_id) diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json index 5ec63be7032b..f9c212f946c9 100644 --- a/homeassistant/components/hassio/strings.json +++ b/homeassistant/components/hassio/strings.json @@ -30,7 +30,20 @@ } }, "abort": { - "apply_suggestion_fail": "Could not apply the fix. Check the supervisor logs for more details." + "apply_suggestion_fail": "Could not apply the fix. Check the Supervisor logs for more details." + } + } + }, + "issue_system_docker_config": { + "title": "Restart(s) required", + "fix_flow": { + "step": { + "system_execute_rebuild": { + "description": "The default configuration for add-ons and Home Assistant has changed. To update the configuration with the new defaults, a restart is required for the following:\n\n- {components}" + } + }, + "abort": { + "apply_suggestion_fail": "One or more of the restarts failed. Check the Supervisor logs for more details." } } }, @@ -43,7 +56,7 @@ } }, "abort": { - "apply_suggestion_fail": "Could not rename the filesystem. Check the supervisor logs for more details." + "apply_suggestion_fail": "Could not rename the filesystem. Check the Supervisor logs for more details." } } }, @@ -56,7 +69,7 @@ } }, "abort": { - "apply_suggestion_fail": "Could not reboot the system. Check the supervisor logs for more details." + "apply_suggestion_fail": "Could not reboot the system. Check the Supervisor logs for more details." } } }, diff --git a/tests/components/hassio/conftest.py b/tests/components/hassio/conftest.py index 678ba641e80e..22051808cccf 100644 --- a/tests/components/hassio/conftest.py +++ b/tests/components/hassio/conftest.py @@ -98,6 +98,10 @@ def all_setup_requests( aioclient_mock: AiohttpClientMocker, request: pytest.FixtureRequest ): """Mock all setup requests.""" + include_addons = hasattr(request, "param") and request.param.get( + "include_addons", False + ) + aioclient_mock.post("http://127.0.0.1/homeassistant/options", json={"result": "ok"}) aioclient_mock.get("http://127.0.0.1/supervisor/ping", json={"result": "ok"}) aioclient_mock.post("http://127.0.0.1/supervisor/options", json={"result": "ok"}) @@ -157,7 +161,30 @@ def all_setup_requests( "version": "1.0.0", "version_latest": "1.0.0", "auto_update": True, - "addons": [], + "addons": [ + { + "name": "test", + "slug": "test", + "update_available": False, + "version": "1.0.0", + "version_latest": "1.0.0", + "repository": "core", + "state": "started", + "icon": False, + }, + { + "name": "test2", + "slug": "test2", + "update_available": False, + "version": "1.0.0", + "version_latest": "1.0.0", + "repository": "core", + "state": "started", + "icon": False, + }, + ] + if include_addons + else [], }, }, ) @@ -165,3 +192,106 @@ def all_setup_requests( "http://127.0.0.1/ingress/panels", json={"result": "ok", "data": {"panels": {}}} ) aioclient_mock.post("http://127.0.0.1/refresh_updates", json={"result": "ok"}) + + aioclient_mock.get("http://127.0.0.1/addons/test/changelog", text="") + aioclient_mock.get( + "http://127.0.0.1/addons/test/info", + json={ + "result": "ok", + "data": { + "name": "test", + "slug": "test", + "update_available": False, + "version": "1.0.0", + "version_latest": "1.0.0", + "repository": "core", + "state": "started", + "icon": False, + "url": "https://github.com/home-assistant/addons/test", + "auto_update": True, + }, + }, + ) + aioclient_mock.get("http://127.0.0.1/addons/test2/changelog", text="") + aioclient_mock.get( + "http://127.0.0.1/addons/test2/info", + json={ + "result": "ok", + "data": { + "name": "test2", + "slug": "test2", + "update_available": False, + "version": "1.0.0", + "version_latest": "1.0.0", + "repository": "core", + "state": "started", + "icon": False, + "url": "https://github.com", + "auto_update": False, + }, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/core/stats", + json={ + "result": "ok", + "data": { + "cpu_percent": 0.99, + "memory_usage": 182611968, + "memory_limit": 3977146368, + "memory_percent": 4.59, + "network_rx": 362570232, + "network_tx": 82374138, + "blk_read": 46010945536, + "blk_write": 15051526144, + }, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/supervisor/stats", + json={ + "result": "ok", + "data": { + "cpu_percent": 0.99, + "memory_usage": 182611968, + "memory_limit": 3977146368, + "memory_percent": 4.59, + "network_rx": 362570232, + "network_tx": 82374138, + "blk_read": 46010945536, + "blk_write": 15051526144, + }, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/addons/test/stats", + json={ + "result": "ok", + "data": { + "cpu_percent": 0.99, + "memory_usage": 182611968, + "memory_limit": 3977146368, + "memory_percent": 4.59, + "network_rx": 362570232, + "network_tx": 82374138, + "blk_read": 46010945536, + "blk_write": 15051526144, + }, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/addons/test2/stats", + json={ + "result": "ok", + "data": { + "cpu_percent": 0.8, + "memory_usage": 51941376, + "memory_limit": 3977146368, + "memory_percent": 1.31, + "network_rx": 31338284, + "network_tx": 15692900, + "blk_read": 740077568, + "blk_write": 6004736, + }, + }, + ) diff --git a/tests/components/hassio/test_issues.py b/tests/components/hassio/test_issues.py index c8ce5fcb4901..7bd30e452c0e 100644 --- a/tests/components/hassio/test_issues.py +++ b/tests/components/hassio/test_issues.py @@ -496,7 +496,7 @@ async def test_supervisor_issues( { "uuid": "1237", "type": "should_not_be_repair", - "context": "fake", + "context": "os", "reference": None, }, ], diff --git a/tests/components/hassio/test_repairs.py b/tests/components/hassio/test_repairs.py index acef1cdd715c..237c20a52728 100644 --- a/tests/components/hassio/test_repairs.py +++ b/tests/components/hassio/test_repairs.py @@ -19,16 +19,11 @@ from tests.typing import ClientSessionGenerator @pytest.fixture(autouse=True) -async def setup_repairs(hass): +async def setup_repairs(hass: HomeAssistant): """Set up the repairs integration.""" assert await async_setup_component(hass, REPAIRS_DOMAIN, {REPAIRS_DOMAIN: {}}) -@pytest.fixture(autouse=True) -async def mock_all(all_setup_requests): - """Mock all setup requests.""" - - @pytest.fixture(autouse=True) async def fixture_supervisor_environ(): """Mock os environ for supervisor.""" @@ -40,9 +35,10 @@ async def test_supervisor_issue_repair_flow( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, hass_client: ClientSessionGenerator, + issue_registry: ir.IssueRegistry, + all_setup_requests, ) -> None: """Test fix flow for supervisor issue.""" - issue_registry: ir.IssueRegistry = ir.async_get(hass) mock_resolution_info( aioclient_mock, issues=[ @@ -63,8 +59,7 @@ async def test_supervisor_issue_repair_flow( ], ) - result = await async_setup_component(hass, "hassio", {}) - assert result + assert await async_setup_component(hass, "hassio", {}) repair_issue = issue_registry.async_get_issue(domain="hassio", issue_id="1234") assert repair_issue @@ -119,9 +114,10 @@ async def test_supervisor_issue_repair_flow_with_multiple_suggestions( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, hass_client: ClientSessionGenerator, + issue_registry: ir.IssueRegistry, + all_setup_requests, ) -> None: """Test fix flow for supervisor issue with multiple suggestions.""" - issue_registry: ir.IssueRegistry = ir.async_get(hass) mock_resolution_info( aioclient_mock, issues=[ @@ -148,8 +144,7 @@ async def test_supervisor_issue_repair_flow_with_multiple_suggestions( ], ) - result = await async_setup_component(hass, "hassio", {}) - assert result + assert await async_setup_component(hass, "hassio", {}) repair_issue = issue_registry.async_get_issue(domain="hassio", issue_id="1234") assert repair_issue @@ -214,9 +209,10 @@ async def test_supervisor_issue_repair_flow_with_multiple_suggestions_and_confir hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, hass_client: ClientSessionGenerator, + issue_registry: ir.IssueRegistry, + all_setup_requests, ) -> None: """Test fix flow for supervisor issue with multiple suggestions and choice requires confirmation.""" - issue_registry: ir.IssueRegistry = ir.async_get(hass) mock_resolution_info( aioclient_mock, issues=[ @@ -243,8 +239,7 @@ async def test_supervisor_issue_repair_flow_with_multiple_suggestions_and_confir ], ) - result = await async_setup_component(hass, "hassio", {}) - assert result + assert await async_setup_component(hass, "hassio", {}) repair_issue = issue_registry.async_get_issue(domain="hassio", issue_id="1234") assert repair_issue @@ -327,9 +322,10 @@ async def test_supervisor_issue_repair_flow_skip_confirmation( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, hass_client: ClientSessionGenerator, + issue_registry: ir.IssueRegistry, + all_setup_requests, ) -> None: """Test confirmation skipped for fix flow for supervisor issue with one suggestion.""" - issue_registry: ir.IssueRegistry = ir.async_get(hass) mock_resolution_info( aioclient_mock, issues=[ @@ -350,8 +346,7 @@ async def test_supervisor_issue_repair_flow_skip_confirmation( ], ) - result = await async_setup_component(hass, "hassio", {}) - assert result + assert await async_setup_component(hass, "hassio", {}) repair_issue = issue_registry.async_get_issue(domain="hassio", issue_id="1234") assert repair_issue @@ -406,9 +401,10 @@ async def test_mount_failed_repair_flow( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, hass_client: ClientSessionGenerator, + issue_registry: ir.IssueRegistry, + all_setup_requests, ) -> None: """Test repair flow for mount_failed issue.""" - issue_registry: ir.IssueRegistry = ir.async_get(hass) mock_resolution_info( aioclient_mock, issues=[ @@ -435,8 +431,7 @@ async def test_mount_failed_repair_flow( ], ) - result = await async_setup_component(hass, "hassio", {}) - assert result + assert await async_setup_component(hass, "hassio", {}) repair_issue = issue_registry.async_get_issue(domain="hassio", issue_id="1234") assert repair_issue @@ -499,3 +494,113 @@ async def test_mount_failed_repair_flow( str(aioclient_mock.mock_calls[-1][1]) == "http://127.0.0.1/resolution/suggestion/1235" ) + + +@pytest.mark.parametrize( + "all_setup_requests", [{"include_addons": True}], indirect=True +) +async def test_supervisor_issue_docker_config_repair_flow( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_client: ClientSessionGenerator, + issue_registry: ir.IssueRegistry, + all_setup_requests, +) -> None: + """Test fix flow for supervisor issue.""" + mock_resolution_info( + aioclient_mock, + issues=[ + { + "uuid": "1234", + "type": "docker_config", + "context": "system", + "reference": None, + "suggestions": [ + { + "uuid": "1235", + "type": "execute_rebuild", + "context": "system", + "reference": None, + } + ], + }, + { + "uuid": "1236", + "type": "docker_config", + "context": "core", + "reference": None, + "suggestions": [ + { + "uuid": "1237", + "type": "execute_rebuild", + "context": "core", + "reference": None, + } + ], + }, + { + "uuid": "1238", + "type": "docker_config", + "context": "addon", + "reference": "test", + "suggestions": [ + { + "uuid": "1239", + "type": "execute_rebuild", + "context": "addon", + "reference": "test", + } + ], + }, + ], + ) + + assert await async_setup_component(hass, "hassio", {}) + + repair_issue = issue_registry.async_get_issue(domain="hassio", issue_id="1234") + assert repair_issue + + client = await hass_client() + + resp = await client.post( + "/api/repairs/issues/fix", + json={"handler": "hassio", "issue_id": repair_issue.issue_id}, + ) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data == { + "type": "form", + "flow_id": flow_id, + "handler": "hassio", + "step_id": "system_execute_rebuild", + "data_schema": [], + "errors": None, + "description_placeholders": {"components": "Home Assistant\n- test"}, + "last_step": True, + } + + resp = await client.post(f"/api/repairs/issues/fix/{flow_id}") + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data == { + "version": 1, + "type": "create_entry", + "flow_id": flow_id, + "handler": "hassio", + "description": None, + "description_placeholders": None, + } + + assert not issue_registry.async_get_issue(domain="hassio", issue_id="1234") + + assert aioclient_mock.mock_calls[-1][0] == "post" + assert ( + str(aioclient_mock.mock_calls[-1][1]) + == "http://127.0.0.1/resolution/suggestion/1235" + ) From 9f4c9f1ce3cc1825061a0c4add6f218a48594852 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 30 May 2023 15:11:08 -0500 Subject: [PATCH 701/822] Bump intents to 2023.5.30 (#93822) --- homeassistant/components/conversation/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/conversation/snapshots/test_init.ambr | 1 + 5 files changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 209f239df00b..06666af815aa 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["hassil==1.0.6", "home-assistant-intents==2023.4.26"] + "requirements": ["hassil==1.0.6", "home-assistant-intents==2023.5.30"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 5816763c8d26..1361c41d74b7 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -26,7 +26,7 @@ hass-nabucasa==0.67.1 hassil==1.0.6 home-assistant-bluetooth==1.10.0 home-assistant-frontend==20230526.0 -home-assistant-intents==2023.4.26 +home-assistant-intents==2023.5.30 httpx==0.24.1 ifaddr==0.2.0 janus==1.0.0 diff --git a/requirements_all.txt b/requirements_all.txt index e224ec3294b6..c15ae28cf24f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -924,7 +924,7 @@ holidays==0.21.13 home-assistant-frontend==20230526.0 # homeassistant.components.conversation -home-assistant-intents==2023.4.26 +home-assistant-intents==2023.5.30 # homeassistant.components.home_connect homeconnect==0.7.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c526b1b88595..2f15bcea2b59 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -716,7 +716,7 @@ holidays==0.21.13 home-assistant-frontend==20230526.0 # homeassistant.components.conversation -home-assistant-intents==2023.4.26 +home-assistant-intents==2023.5.30 # homeassistant.components.home_connect homeconnect==0.7.2 diff --git a/tests/components/conversation/snapshots/test_init.ambr b/tests/components/conversation/snapshots/test_init.ambr index 7284b83cb771..61e499b15daf 100644 --- a/tests/components/conversation/snapshots/test_init.ambr +++ b/tests/components/conversation/snapshots/test_init.ambr @@ -56,6 +56,7 @@ 'it', 'ka', 'kn', + 'ko', 'lb', 'lt', 'lv', From 1dcabae76091b1938ffd12959c83ea258a850ba7 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 30 May 2023 15:18:55 -0500 Subject: [PATCH 702/822] Don't skip chunk before speech (#93825) --- homeassistant/components/voip/voip.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/voip/voip.py b/homeassistant/components/voip/voip.py index 8b96941e00a0..8ef03e0ddf0c 100644 --- a/homeassistant/components/voip/voip.py +++ b/homeassistant/components/voip/voip.py @@ -285,13 +285,13 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): chunk = await self._audio_queue.get() while chunk: + chunk_buffer.append(chunk) + segmenter.process(chunk) if segmenter.in_command: + # Buffer until command starts return True - # Buffer until command starts - chunk_buffer.append(chunk) - async with async_timeout.timeout(self.audio_timeout): chunk = await self._audio_queue.get() From c7c4ee13c33c4e215233386b6d71ddbea4b39c24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Tue, 30 May 2023 23:30:31 +0300 Subject: [PATCH 703/822] Import `util.dt` as `dt_util` in remaining tests, enforce with ruff (#93765) * Import `util.dt` as `dt_util` in remaining tests, enforce with ruff * Fix `util.dt` import in pvpc_hourly_pricing tests --- pyproject.toml | 1 + tests/common.py | 12 ++++++------ .../pvpc_hourly_pricing/test_config_flow.py | 5 +++-- tests/helpers/test_storage.py | 16 ++++++++-------- tests/helpers/test_storage_remove.py | 4 ++-- tests/test_config_entries.py | 8 ++++---- 6 files changed, 24 insertions(+), 22 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index caf904d86d31..9bb55d3d29d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -284,6 +284,7 @@ voluptuous = "vol" "homeassistant.helpers.device_registry" = "dr" "homeassistant.helpers.entity_registry" = "er" "homeassistant.helpers.issue_registry" = "ir" +"homeassistant.util.dt" = "dt_util" [tool.ruff.flake8-pytest-style] fixture-parentheses = false diff --git a/tests/common.py b/tests/common.py index 65e119c8c49c..f0d7a8de3c3b 100644 --- a/tests/common.py +++ b/tests/common.py @@ -68,7 +68,7 @@ from homeassistant.helpers.json import JSONEncoder from homeassistant.helpers.typing import ConfigType, StateType from homeassistant.setup import setup_component from homeassistant.util.async_ import run_callback_threadsafe -import homeassistant.util.dt as date_util +import homeassistant.util.dt as dt_util from homeassistant.util.json import ( JsonArrayType, JsonObjectType, @@ -357,7 +357,7 @@ def async_fire_time_changed_exact( if datetime_ is None: utc_datetime = datetime.now(timezone.utc) else: - utc_datetime = date_util.as_utc(datetime_) + utc_datetime = dt_util.as_utc(datetime_) _async_fire_time_changed(hass, utc_datetime, fire_all) @@ -379,7 +379,7 @@ def async_fire_time_changed( if datetime_ is None: utc_datetime = datetime.now(timezone.utc) else: - utc_datetime = date_util.as_utc(datetime_) + utc_datetime = dt_util.as_utc(datetime_) if utc_datetime.microsecond < 500000: # Allow up to 500000 microseconds to be added to the time @@ -395,7 +395,7 @@ def async_fire_time_changed( def _async_fire_time_changed( hass: HomeAssistant, utc_datetime: datetime | None, fire_all: bool ) -> None: - timestamp = date_util.utc_to_timestamp(utc_datetime) + timestamp = dt_util.utc_to_timestamp(utc_datetime) for task in list(hass.loop._scheduled): if not isinstance(task, asyncio.TimerHandle): continue @@ -1012,7 +1012,7 @@ def mock_restore_cache(hass: HomeAssistant, states: Sequence[State]) -> None: """Mock the DATA_RESTORE_CACHE.""" key = restore_state.DATA_RESTORE_STATE_TASK data = restore_state.RestoreStateData(hass) - now = date_util.utcnow() + now = dt_util.utcnow() last_states = {} for state in states: @@ -1039,7 +1039,7 @@ def mock_restore_cache_with_extra_data( """Mock the DATA_RESTORE_CACHE.""" key = restore_state.DATA_RESTORE_STATE_TASK data = restore_state.RestoreStateData(hass) - now = date_util.utcnow() + now = dt_util.utcnow() last_states = {} for state, extra_data in states: diff --git a/tests/components/pvpc_hourly_pricing/test_config_flow.py b/tests/components/pvpc_hourly_pricing/test_config_flow.py index 360e7f7f7568..8623830f0dd1 100644 --- a/tests/components/pvpc_hourly_pricing/test_config_flow.py +++ b/tests/components/pvpc_hourly_pricing/test_config_flow.py @@ -14,13 +14,14 @@ from homeassistant.components.pvpc_hourly_pricing import ( from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.util import dt as dt_util from .conftest import check_valid_state -from tests.common import async_fire_time_changed, date_util +from tests.common import async_fire_time_changed from tests.test_util.aiohttp import AiohttpClientMocker -_MOCK_TIME_VALID_RESPONSES = datetime(2023, 1, 6, 12, 0, tzinfo=date_util.UTC) +_MOCK_TIME_VALID_RESPONSES = datetime(2023, 1, 6, 12, 0, tzinfo=dt_util.UTC) async def test_config_flow( diff --git a/tests/helpers/test_storage.py b/tests/helpers/test_storage.py index b60e072d2f15..76dfbdbeb46e 100644 --- a/tests/helpers/test_storage.py +++ b/tests/helpers/test_storage.py @@ -14,7 +14,7 @@ from homeassistant.const import ( ) from homeassistant.core import CoreState, HomeAssistant from homeassistant.helpers import storage -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from homeassistant.util.color import RGBColor from tests.common import async_fire_time_changed, async_test_home_assistant @@ -113,7 +113,7 @@ async def test_saving_with_delay( store.async_delay_save(lambda: MOCK_DATA, 1) assert store.key not in hass_storage - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=1)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1)) await hass.async_block_till_done() assert hass_storage[store.key] == { "version": MOCK_VERSION, @@ -135,7 +135,7 @@ async def test_saving_on_final_write( hass.state = CoreState.stopping await hass.async_block_till_done() - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=10)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10)) await hass.async_block_till_done() assert store.key not in hass_storage @@ -159,7 +159,7 @@ async def test_not_delayed_saving_while_stopping( hass.state = CoreState.stopping store.async_delay_save(lambda: MOCK_DATA, 1) - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=2)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=2)) await hass.async_block_till_done() assert store.key not in hass_storage @@ -177,7 +177,7 @@ async def test_not_delayed_saving_after_stopping( await hass.async_block_till_done() assert store.key not in hass_storage - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=15)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=15)) await hass.async_block_till_done() assert store.key not in hass_storage @@ -230,7 +230,7 @@ async def test_writing_while_writing_delay( "data": {"delay": "no"}, } - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=1)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1)) await hass.async_block_till_done() assert hass_storage[store.key] == { "version": MOCK_VERSION, @@ -260,7 +260,7 @@ async def test_multiple_delay_save_calls( "data": {"delay": "no"}, } - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=1)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1)) await hass.async_block_till_done() assert hass_storage[store.key] == { "version": MOCK_VERSION, @@ -496,7 +496,7 @@ async def test_changing_delayed_written_data( loaded_data["hello"] = "earth" - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=1)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1)) await hass.async_block_till_done() assert hass_storage[store.key] == { "version": MOCK_VERSION, diff --git a/tests/helpers/test_storage_remove.py b/tests/helpers/test_storage_remove.py index 21eabe80e45a..e948f75ea5f1 100644 --- a/tests/helpers/test_storage_remove.py +++ b/tests/helpers/test_storage_remove.py @@ -7,7 +7,7 @@ from unittest.mock import patch import py from homeassistant.helpers import storage -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed, async_test_home_assistant @@ -32,7 +32,7 @@ async def test_removing_while_delay_in_progress(tmpdir: py.path.local) -> None: await real_store.async_remove() assert not await hass.async_add_executor_job(os.path.exists, real_store.path) - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=1)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1)) await hass.async_block_till_done() assert not await hass.async_add_executor_job(os.path.exists, real_store.path) await hass.async_stop() diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 774c85dbc0d4..b20f2af76691 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -31,7 +31,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.setup import async_set_domains_to_be_loaded, async_setup_component -from homeassistant.util import dt import homeassistant.util.dt as dt_util from .common import ( @@ -650,7 +649,7 @@ async def test_saving_and_loading(hass: HomeAssistant) -> None: ) # To trigger the call_later - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=1)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1)) # To execute the save await hass.async_block_till_done() @@ -1611,7 +1610,8 @@ async def test_reload_entry_entity_registry_works( async_fire_time_changed( hass, - dt.utcnow() + timedelta(seconds=config_entries.RELOAD_AFTER_UPDATE_DELAY + 1), + dt_util.utcnow() + + timedelta(seconds=config_entries.RELOAD_AFTER_UPDATE_DELAY + 1), ) await hass.async_block_till_done() @@ -3303,7 +3303,7 @@ async def test_setup_retrying_during_shutdown(hass: HomeAssistant) -> None: assert len(mock_call.return_value.mock_calls) == 0 - async_fire_time_changed(hass, dt.utcnow() + timedelta(hours=4)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(hours=4)) await hass.async_block_till_done() assert len(mock_call.return_value.mock_calls) == 0 From 58259f11035c1efaf3c27ebea4fec5aaa8865db4 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 30 May 2023 23:10:08 +0200 Subject: [PATCH 704/822] Get nightly intents from new intents-package repo (#93826) Get nighlty intents from new intents-package repo --- .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 ae650d3052a0..063342cc6b7f 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -124,7 +124,7 @@ jobs: uses: dawidd6/action-download-artifact@v2 with: github_token: ${{secrets.GITHUB_TOKEN}} - repo: home-assistant/intents + repo: home-assistant/intents-package branch: main workflow: nightly.yaml workflow_conclusion: success From 49f10eecaa9877091c777857d92ce3e88b4b3c9f Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Tue, 30 May 2023 23:13:08 +0200 Subject: [PATCH 705/822] Fix false reolink ONVIF webhook warning (#91015) --- homeassistant/components/reolink/host.py | 72 +++++++++++++++--------- 1 file changed, 46 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index 5ff79029bd1c..c57f7b1e77e6 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -8,7 +8,6 @@ from typing import Any import aiohttp from aiohttp.web import Request -import async_timeout from reolink_aio.api import Host from reolink_aio.exceptions import ReolinkError, SubscriptionError @@ -62,6 +61,7 @@ class ReolinkHost: self._webhook_url: str = "" self._webhook_reachable: asyncio.Event = asyncio.Event() self._cancel_poll: CALLBACK_TYPE | None = None + self._cancel_onvif_check: CALLBACK_TYPE | None = None self._poll_job = HassJob(self._async_poll_all_motion, cancel_on_shutdown=True) self._lost_subscription: bool = False @@ -148,34 +148,19 @@ class ReolinkHost: await self.subscribe() - _LOGGER.debug( - "Waiting for initial ONVIF state on webhook '%s'", self._webhook_url - ) - try: - async with async_timeout.timeout(FIRST_ONVIF_TIMEOUT): - await self._webhook_reachable.wait() - except asyncio.TimeoutError: + if self._api.supported(None, "initial_ONVIF_state"): _LOGGER.debug( - "Did not receive initial ONVIF state on webhook '%s' after %i seconds", - self._webhook_url, - FIRST_ONVIF_TIMEOUT, + "Waiting for initial ONVIF state on webhook '%s'", self._webhook_url ) - ir.async_create_issue( - self._hass, - DOMAIN, - "webhook_url", - is_fixable=False, - severity=ir.IssueSeverity.WARNING, - translation_key="webhook_url", - translation_placeholders={ - "name": self._api.nvr_name, - "base_url": self._base_url, - "network_link": "https://my.home-assistant.io/redirect/network/", - }, - ) - await self._async_poll_all_motion() else: - ir.async_delete_issue(self._hass, DOMAIN, "webhook_url") + _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 self._api.sw_version_update_required: ir.async_create_issue( @@ -197,6 +182,38 @@ class ReolinkHost: else: ir.async_delete_issue(self._hass, DOMAIN, "firmware_update") + async def _async_check_onvif(self, *_) -> None: + """Check the ONVIF subscription.""" + if ( + self._api.supported(None, "initial_ONVIF_state") + and not self._webhook_reachable.is_set() + ): + _LOGGER.debug( + "Did not receive initial ONVIF state on webhook '%s' after %i seconds", + self._webhook_url, + FIRST_ONVIF_TIMEOUT, + ) + ir.async_create_issue( + self._hass, + DOMAIN, + "webhook_url", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="webhook_url", + translation_placeholders={ + "name": self._api.nvr_name, + "base_url": self._base_url, + "network_link": "https://my.home-assistant.io/redirect/network/", + }, + ) + else: + ir.async_delete_issue(self._hass, DOMAIN, "webhook_url") + + # If no ONVIF push is received, start fast polling + await self._async_poll_all_motion() + + self._cancel_onvif_check = None + async def update_states(self) -> None: """Call the API of the camera device to update the internal states.""" await self._api.get_states() @@ -236,6 +253,9 @@ class ReolinkHost: if self._cancel_poll is not None: self._cancel_poll() self._cancel_poll = None + if self._cancel_onvif_check is not None: + self._cancel_onvif_check() + self._cancel_onvif_check = None self.unregister_webhook() await self.disconnect() From d7d9143a44df6c0a2e0e1695fa96c9081430e67b Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 31 May 2023 11:49:22 +1200 Subject: [PATCH 706/822] Add speech detection and conversation_id to esphome voice assistant (#93578) * Add speech detection to esphome voice assistant * Timeout after silence Ensure events are sent before finish is called * use va_version 3 instead of ESPHome version * Convert repeated fixtures to factory * Add some v3 tests * Add conversation_id * Bump aioesphomeapi to 13.8.0 * Fix missed buffering of detected chunk * Alter log message * Updates * Spelling * Fix return type --- homeassistant/components/esphome/__init__.py | 7 +- .../components/esphome/manifest.json | 2 +- .../components/esphome/voice_assistant.py | 166 ++++++++++++++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/esphome/conftest.py | 87 ++++---- .../esphome/test_voice_assistant.py | 187 +++++++++++++++--- 7 files changed, 352 insertions(+), 101 deletions(-) diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index a68dd562af14..f95763d3a6c4 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -302,7 +302,7 @@ async def async_setup_entry( # noqa: C901 voice_assistant_udp_server.close() voice_assistant_udp_server = None - async def _handle_pipeline_start() -> int | None: + async def _handle_pipeline_start(conversation_id: str, use_vad: bool) -> int | None: """Start a voice assistant pipeline.""" nonlocal voice_assistant_udp_server @@ -315,7 +315,10 @@ async def async_setup_entry( # noqa: C901 port = await voice_assistant_udp_server.start_server() hass.async_create_background_task( - voice_assistant_udp_server.run_pipeline(), + voice_assistant_udp_server.run_pipeline( + conversation_id=conversation_id or None, + use_vad=use_vad, + ), "esphome.voice_assistant_udp_server.run_pipeline", ) entry_data.async_set_assist_pipeline_state(True) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 49057080469c..c6e430d78451 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -15,7 +15,7 @@ "iot_class": "local_push", "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ - "aioesphomeapi==13.7.5", + "aioesphomeapi==13.9.0", "bluetooth-data-tools==0.4.0", "esphome-dashboard-api==1.2.3" ], diff --git a/homeassistant/components/esphome/voice_assistant.py b/homeassistant/components/esphome/voice_assistant.py index aaa2dc80a78c..5cd9c1d931f0 100644 --- a/homeassistant/components/esphome/voice_assistant.py +++ b/homeassistant/components/esphome/voice_assistant.py @@ -2,7 +2,8 @@ from __future__ import annotations import asyncio -from collections.abc import AsyncIterable, Callable +from collections import deque +from collections.abc import AsyncIterable, Callable, MutableSequence, Sequence import logging import socket from typing import cast @@ -17,6 +18,7 @@ from homeassistant.components.assist_pipeline import ( async_pipeline_from_audio_stream, select as pipeline_select, ) +from homeassistant.components.assist_pipeline.vad import VoiceCommandSegmenter from homeassistant.components.media_player import async_process_play_media_url from homeassistant.core import Context, HomeAssistant, callback @@ -50,7 +52,7 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): """Receive UDP packets and forward them to the voice assistant.""" started = False - queue: asyncio.Queue[bytes] | None = None + stopped = False transport: asyncio.DatagramTransport | None = None remote_addr: tuple[str, int] | None = None @@ -60,6 +62,7 @@ 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() @@ -68,10 +71,11 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): assert entry_data.device_info is not None self.device_info = entry_data.device_info - self.queue = asyncio.Queue() + self.queue: asyncio.Queue[bytes] = asyncio.Queue() 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.""" @@ -80,7 +84,7 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): """Accept connection.""" if self.started: raise RuntimeError("Can only start once") - if self.queue is None: + if self.stopped: raise RuntimeError("No longer accepting connections") self.started = True @@ -105,12 +109,11 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): @callback def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None: """Handle incoming UDP packet.""" - if not self.started: + if not self.started or self.stopped: return if self.remote_addr is None: self.remote_addr = addr - if self.queue is not None: - self.queue.put_nowait(data) + self.queue.put_nowait(data) def error_received(self, exc: Exception) -> None: """Handle when a send or receive operation raises an OSError. @@ -123,21 +126,21 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): @callback def stop(self) -> None: """Stop the receiver.""" - if self.queue is not None: - self.queue.put_nowait(b"") + self.queue.put_nowait(b"") self.started = False + self.stopped = True def close(self) -> None: """Close the receiver.""" - if self.queue is not None: - self.queue = None + self.started = False + self.stopped = True if self.transport is not None: self.transport.close() async def _iterate_packets(self) -> AsyncIterable[bytes]: """Iterate over incoming packets.""" - if self.queue is None: - raise RuntimeError("Already stopped") + if not self.started or self.stopped: + raise RuntimeError("Not running") while data := await self.queue.get(): yield data @@ -152,9 +155,15 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): return data_to_send = None + error = False if event_type == VoiceAssistantEventType.VOICE_ASSISTANT_STT_END: assert event.data is not None data_to_send = {"text": event.data["stt_output"]["text"]} + elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END: + assert event.data is not None + data_to_send = { + "conversation_id": event.data["intent_output"]["conversation_id"] or "", + } elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_START: assert event.data is not None data_to_send = {"text": event.data["tts_input"]} @@ -177,19 +186,132 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): "code": event.data["code"], "message": event.data["message"], } - self.handle_finished() + self._tts_done.set() + error = True self.handle_event(event_type, data_to_send) + if error: + self.handle_finished() + + async def _wait_for_speech( + self, + segmenter: VoiceCommandSegmenter, + chunk_buffer: MutableSequence[bytes], + ) -> bool: + """Buffer audio chunks until speech is detected. + + Raises asyncio.TimeoutError if no audio data is retrievable from the queue (device stops sending packets / networking issue). + + Returns True if speech was detected + Returns False if the connection was stopped gracefully (b"" put onto the queue). + """ + # Timeout if no audio comes in for a while. + async with async_timeout.timeout(self.audio_timeout): + chunk = await self.queue.get() + + while chunk: + segmenter.process(chunk) + # Buffer the data we have taken from the queue + chunk_buffer.append(chunk) + if segmenter.in_command: + return True + + async with async_timeout.timeout(self.audio_timeout): + chunk = await self.queue.get() + + # If chunk is falsey, `stop()` was called + return False + + async def _segment_audio( + self, + segmenter: VoiceCommandSegmenter, + chunk_buffer: Sequence[bytes], + ) -> AsyncIterable[bytes]: + """Yield audio chunks until voice command has finished. + + Raises asyncio.TimeoutError if no audio data is retrievable from the queue. + """ + # Buffered chunks first + for buffered_chunk in chunk_buffer: + yield buffered_chunk + + # Timeout if no audio comes in for a while. + async with async_timeout.timeout(self.audio_timeout): + chunk = await self.queue.get() + + while chunk: + if not segmenter.process(chunk): + # Voice command is finished + break + + yield chunk + + async with async_timeout.timeout(self.audio_timeout): + chunk = await self.queue.get() + + async def _iterate_packets_with_vad( + self, pipeline_timeout: float + ) -> Callable[[], AsyncIterable[bytes]] | None: + segmenter = VoiceCommandSegmenter() + chunk_buffer: deque[bytes] = deque(maxlen=100) + try: + async with async_timeout.timeout(pipeline_timeout): + speech_detected = await self._wait_for_speech(segmenter, chunk_buffer) + if not speech_detected: + _LOGGER.debug( + "Device stopped sending audio before speech was detected" + ) + self.handle_finished() + return None + except asyncio.TimeoutError: + self.handle_event( + VoiceAssistantEventType.VOICE_ASSISTANT_ERROR, + { + "code": "speech-timeout", + "message": "Timed out waiting for speech", + }, + ) + self.handle_finished() + return None + + async def _stream_packets() -> AsyncIterable[bytes]: + try: + async for chunk in self._segment_audio(segmenter, chunk_buffer): + yield chunk + except asyncio.TimeoutError: + self.handle_event( + VoiceAssistantEventType.VOICE_ASSISTANT_ERROR, + { + "code": "speech-timeout", + "message": "No speech detected", + }, + ) + self.handle_finished() + + return _stream_packets async def run_pipeline( self, + conversation_id: str | None, + use_vad: bool = False, pipeline_timeout: float = 30.0, ) -> None: """Run the Voice Assistant pipeline.""" + + tts_audio_output = ( + "raw" if self.device_info.voice_assistant_version >= 2 else "mp3" + ) + + if use_vad: + stt_stream = await self._iterate_packets_with_vad(pipeline_timeout) + # Error or timeout occurred and was handled already + if stt_stream is None: + return + else: + stt_stream = self._iterate_packets + + _LOGGER.debug("Starting pipeline") try: - tts_audio_output = ( - "raw" if self.device_info.voice_assistant_version >= 2 else "mp3" - ) async with async_timeout.timeout(pipeline_timeout): await async_pipeline_from_audio_stream( self.hass, @@ -203,10 +325,11 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, channel=stt.AudioChannels.CHANNEL_MONO, ), - stt_stream=self._iterate_packets(), + stt_stream=stt_stream(), pipeline_id=pipeline_select.get_chosen_pipeline( self.hass, DOMAIN, self.device_info.mac_address ), + conversation_id=conversation_id, tts_audio_output=tts_audio_output, ) @@ -215,6 +338,13 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): _LOGGER.debug("Pipeline finished") except asyncio.TimeoutError: + 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 c15ae28cf24f..aa99c0725a5a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -159,7 +159,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==13.7.5 +aioesphomeapi==13.9.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2f15bcea2b59..6ed7a309aa27 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -149,7 +149,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==13.7.5 +aioesphomeapi==13.9.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index 4c01c2f7c5a2..23f140587c7c 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -132,70 +132,51 @@ async def mock_dashboard(hass): @pytest.fixture -async def mock_voice_assistant_v1_entry( +async def mock_voice_assistant_entry( hass: HomeAssistant, mock_client, ) -> MockConfigEntry: """Set up an ESPHome entry with voice assistant.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={ - CONF_HOST: "test.local", - CONF_PORT: 6053, - CONF_PASSWORD: "", - }, - ) - entry.add_to_hass(hass) - device_info = DeviceInfo( - name="test", - friendly_name="Test", - voice_assistant_version=1, - mac_address="11:22:33:44:55:aa", - esphome_version="1.0.0", - ) + async def _mock_voice_assistant_entry(version: int): + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "test.local", + CONF_PORT: 6053, + CONF_PASSWORD: "", + }, + ) + entry.add_to_hass(hass) - mock_client.device_info = AsyncMock(return_value=device_info) - mock_client.subscribe_voice_assistant = AsyncMock(return_value=Mock()) + device_info = DeviceInfo( + name="test", + friendly_name="Test", + voice_assistant_version=version, + mac_address="11:22:33:44:55:aa", + esphome_version="1.0.0", + ) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - await hass.async_block_till_done() - await hass.async_block_till_done() + mock_client.device_info = AsyncMock(return_value=device_info) + mock_client.subscribe_voice_assistant = AsyncMock(return_value=Mock()) - return entry + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + await hass.async_block_till_done() + await hass.async_block_till_done() + + return entry + + return _mock_voice_assistant_entry @pytest.fixture -async def mock_voice_assistant_v2_entry( - hass: HomeAssistant, - mock_client, -) -> MockConfigEntry: +async def mock_voice_assistant_v1_entry(mock_voice_assistant_entry) -> MockConfigEntry: """Set up an ESPHome entry with voice assistant.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={ - CONF_HOST: "test.local", - CONF_PORT: 6053, - CONF_PASSWORD: "", - }, - ) - entry.add_to_hass(hass) + return await mock_voice_assistant_entry(version=1) - device_info = DeviceInfo( - name="test", - friendly_name="Test", - voice_assistant_version=2, - mac_address="11:22:33:44:55:aa", - esphome_version="1.0.0", - ) - mock_client.device_info = AsyncMock(return_value=device_info) - mock_client.subscribe_voice_assistant = AsyncMock(return_value=Mock()) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - await hass.async_block_till_done() - await hass.async_block_till_done() - - return entry +@pytest.fixture +async def mock_voice_assistant_v2_entry(mock_voice_assistant_entry) -> MockConfigEntry: + """Set up an ESPHome entry with voice assistant.""" + return await mock_voice_assistant_entry(version=2) diff --git a/tests/components/esphome/test_voice_assistant.py b/tests/components/esphome/test_voice_assistant.py index fed83f8ab101..f8c2d62d0956 100644 --- a/tests/components/esphome/test_voice_assistant.py +++ b/tests/components/esphome/test_voice_assistant.py @@ -19,43 +19,47 @@ _TEST_OUTPUT_TEXT = "This is an output test" _TEST_OUTPUT_URL = "output.mp3" _TEST_MEDIA_ID = "12345" +_ONE_SECOND = 16000 * 2 # 16Khz 16-bit + + +@pytest.fixture +def voice_assistant_udp_server( + hass: HomeAssistant, +) -> VoiceAssistantUDPServer: + """Return the UDP server factory.""" + + def _voice_assistant_udp_server(entry): + entry_data = DomainData.get(hass).get_entry_data(entry) + + server: VoiceAssistantUDPServer = None + + def handle_finished(): + nonlocal server + assert server is not None + server.close() + + server = VoiceAssistantUDPServer(hass, entry_data, Mock(), handle_finished) + return server + + return _voice_assistant_udp_server + @pytest.fixture def voice_assistant_udp_server_v1( - hass: HomeAssistant, + voice_assistant_udp_server, mock_voice_assistant_v1_entry, ) -> VoiceAssistantUDPServer: """Return the UDP server.""" - entry_data = DomainData.get(hass).get_entry_data(mock_voice_assistant_v1_entry) - - server: VoiceAssistantUDPServer = None - - def handle_finished(): - nonlocal server - assert server is not None - server.close() - - server = VoiceAssistantUDPServer(hass, entry_data, Mock(), handle_finished) - return server + return voice_assistant_udp_server(entry=mock_voice_assistant_v1_entry) @pytest.fixture def voice_assistant_udp_server_v2( - hass: HomeAssistant, + voice_assistant_udp_server, mock_voice_assistant_v2_entry, ) -> VoiceAssistantUDPServer: """Return the UDP server.""" - entry_data = DomainData.get(hass).get_entry_data(mock_voice_assistant_v2_entry) - - server: VoiceAssistantUDPServer = None - - def handle_finished(): - nonlocal server - assert server is not None - server.close() - - server = VoiceAssistantUDPServer(hass, entry_data, Mock(), handle_finished) - return server + return voice_assistant_udp_server(entry=mock_voice_assistant_v2_entry) async def test_pipeline_events( @@ -117,7 +121,7 @@ async def test_pipeline_events( ): voice_assistant_udp_server_v1.transport = Mock() - await voice_assistant_udp_server_v1.run_pipeline() + await voice_assistant_udp_server_v1.run_pipeline(conversation_id=None) async def test_udp_server( @@ -335,3 +339,136 @@ async def test_send_tts( await voice_assistant_udp_server_v2._tts_done.wait() voice_assistant_udp_server_v2.transport.sendto.assert_called() + + +async def test_speech_detection( + hass: HomeAssistant, + voice_assistant_udp_server_v2: VoiceAssistantUDPServer, +) -> None: + """Test the UDP server queues incoming data.""" + + def is_speech(self, chunk, sample_rate): + """Anything non-zero is speech.""" + return sum(chunk) > 0 + + async def async_pipeline_from_audio_stream(*args, **kwargs): + stt_stream = kwargs["stt_stream"] + event_callback = kwargs["event_callback"] + async for _chunk in stt_stream: + pass + + # Test empty data + event_callback( + PipelineEvent( + type=PipelineEventType.STT_END, + data={"stt_output": {"text": _TEST_INPUT_TEXT}}, + ) + ) + + with patch( + "webrtcvad.Vad.is_speech", + new=is_speech, + ), patch( + "homeassistant.components.esphome.voice_assistant.async_pipeline_from_audio_stream", + new=async_pipeline_from_audio_stream, + ): + voice_assistant_udp_server_v2.started = True + + voice_assistant_udp_server_v2.queue.put_nowait(bytes(_ONE_SECOND)) + voice_assistant_udp_server_v2.queue.put_nowait(bytes([255] * _ONE_SECOND * 2)) + voice_assistant_udp_server_v2.queue.put_nowait(bytes([255] * _ONE_SECOND * 2)) + voice_assistant_udp_server_v2.queue.put_nowait(bytes(_ONE_SECOND)) + + await voice_assistant_udp_server_v2.run_pipeline( + conversation_id=None, use_vad=True, pipeline_timeout=1.0 + ) + + +async def test_no_speech( + hass: HomeAssistant, + voice_assistant_udp_server_v2: VoiceAssistantUDPServer, +) -> None: + """Test there is no speech.""" + + def is_speech(self, chunk, sample_rate): + """Anything non-zero is speech.""" + return sum(chunk) > 0 + + def handle_event( + event_type: esphome.VoiceAssistantEventType, data: dict[str, str] | None + ) -> None: + assert event_type == esphome.VoiceAssistantEventType.VOICE_ASSISTANT_ERROR + assert data is not None + assert data["code"] == "speech-timeout" + + voice_assistant_udp_server_v2.handle_event = handle_event + + with patch( + "webrtcvad.Vad.is_speech", + new=is_speech, + ): + voice_assistant_udp_server_v2.started = True + + voice_assistant_udp_server_v2.queue.put_nowait(bytes(_ONE_SECOND)) + + await voice_assistant_udp_server_v2.run_pipeline( + conversation_id=None, use_vad=True, pipeline_timeout=1.0 + ) + + +async def test_speech_timeout( + hass: HomeAssistant, + voice_assistant_udp_server_v2: VoiceAssistantUDPServer, +) -> None: + """Test when speech was detected, but the pipeline times out.""" + + def is_speech(self, chunk, sample_rate): + """Anything non-zero is speech.""" + return sum(chunk) > 255 + + async def async_pipeline_from_audio_stream(*args, **kwargs): + stt_stream = kwargs["stt_stream"] + async for _chunk in stt_stream: + # Stream will end when VAD detects end of "speech" + pass + + async def segment_audio(*args, **kwargs): + raise asyncio.TimeoutError() + async for chunk in []: + yield chunk + + with patch( + "webrtcvad.Vad.is_speech", + new=is_speech, + ), patch( + "homeassistant.components.esphome.voice_assistant.async_pipeline_from_audio_stream", + new=async_pipeline_from_audio_stream, + ), patch( + "homeassistant.components.esphome.voice_assistant.VoiceAssistantUDPServer._segment_audio", + new=segment_audio, + ): + voice_assistant_udp_server_v2.started = True + + voice_assistant_udp_server_v2.queue.put_nowait(bytes([255] * (_ONE_SECOND * 2))) + + await voice_assistant_udp_server_v2.run_pipeline( + conversation_id=None, use_vad=True, pipeline_timeout=1.0 + ) + + +async def test_cancelled( + hass: HomeAssistant, + voice_assistant_udp_server_v2: VoiceAssistantUDPServer, +) -> None: + """Test when the server is stopped while waiting for speech.""" + + voice_assistant_udp_server_v2.started = True + + voice_assistant_udp_server_v2.queue.put_nowait(b"") + + await voice_assistant_udp_server_v2.run_pipeline( + conversation_id=None, use_vad=True, pipeline_timeout=1.0 + ) + + # No events should be sent if cancelled while waiting for speech + voice_assistant_udp_server_v2.handle_event.assert_not_called() From fe472e6c5ba0f300309341af1aa6810d4a54f1fc Mon Sep 17 00:00:00 2001 From: Tudor Sandu Date: Wed, 31 May 2023 02:53:52 +0300 Subject: [PATCH 707/822] Fix error if a `custom_sentences` file is empty (#93530) * Fix #93528 * Log warning file is invalid * More explicit log warning message * Rewrite log message --- .../components/conversation/default_agent.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index f0946010b124..5cb4487de657 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -413,9 +413,18 @@ class DefaultAgent(AbstractConversationAgent): encoding="utf-8" ) as custom_sentences_file: # Merge custom sentences - merge_dict( - intents_dict, yaml.safe_load(custom_sentences_file) - ) + if isinstance( + custom_sentences_yaml := yaml.safe_load( + custom_sentences_file + ), + dict, + ): + merge_dict(intents_dict, custom_sentences_yaml) + else: + _LOGGER.warning( + "Custom sentences file does not match expected format path=%s", + custom_sentences_file.name, + ) # Will need to recreate graph intents_changed = True From 0653aed49fd0d2b4c3040594e000585dafcd8ddb Mon Sep 17 00:00:00 2001 From: Yuxin Wang Date: Tue, 30 May 2023 20:01:05 -0400 Subject: [PATCH 708/822] Directly check string representation of sensor states in APCUPSD tests (#93783) * Directly check the string representation of sensor states * Fix expected state value for sensor.ups_nominal_output_power --- tests/components/apcupsd/test_sensor.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/tests/components/apcupsd/test_sensor.py b/tests/components/apcupsd/test_sensor.py index bf797db7dbe8..3cc2f0a5620c 100644 --- a/tests/components/apcupsd/test_sensor.py +++ b/tests/components/apcupsd/test_sensor.py @@ -1,5 +1,4 @@ """Test sensors of APCUPSd integration.""" -import pytest from homeassistant.components.sensor import ( ATTR_STATE_CLASS, @@ -35,7 +34,7 @@ async def test_sensor(hass: HomeAssistant) -> None: # Test two representative voltage sensors. state = hass.states.get("sensor.ups_input_voltage") assert state - assert pytest.approx(float(state.state)) == 124.0 + assert state.state == "124.0" assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfElectricPotential.VOLT ) @@ -47,7 +46,7 @@ async def test_sensor(hass: HomeAssistant) -> None: state = hass.states.get("sensor.ups_battery_voltage") assert state - assert pytest.approx(float(state.state)) == 13.7 + assert state.state == "13.7" assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfElectricPotential.VOLT ) @@ -60,7 +59,7 @@ async def test_sensor(hass: HomeAssistant) -> None: # Test a representative percentage sensor. state = hass.states.get("sensor.ups_load") assert state - assert pytest.approx(float(state.state)) == 14.0 + assert state.state == "14.0" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT entry = registry.async_get("sensor.ups_load") @@ -70,7 +69,7 @@ async def test_sensor(hass: HomeAssistant) -> None: # Test a representative wattage sensor. state = hass.states.get("sensor.ups_nominal_output_power") assert state - assert pytest.approx(float(state.state)) == 330.0 + assert state.state == "330" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfPower.WATT assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER entry = registry.async_get("sensor.ups_nominal_output_power") From 31e217a11ec48978dda8d233e04fa618e482e751 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Wed, 31 May 2023 02:03:21 +0200 Subject: [PATCH 709/822] Update iotawattpy to use our own fork (#93830) Unfortunately iotawattpy got dormant. Use our fork hosted at in the [home-assistant-libs/iotawattpy](https://github.com/home-assistant-libs/iotawattpy) repository and available on PyPI as ha-iotawatt instead. The first release of the fork also contains a fix for issue #82869. --- homeassistant/components/iotawatt/manifest.json | 2 +- requirements_all.txt | 6 +++--- requirements_test_all.txt | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/iotawatt/manifest.json b/homeassistant/components/iotawatt/manifest.json index 1a25a26ee35f..5beaa1e318ca 100644 --- a/homeassistant/components/iotawatt/manifest.json +++ b/homeassistant/components/iotawatt/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/iotawatt", "iot_class": "local_polling", "loggers": ["iotawattpy"], - "requirements": ["iotawattpy==0.1.0"] + "requirements": ["ha-iotawattpy==0.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index aa99c0725a5a..539fd0c81c31 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -875,6 +875,9 @@ ha-av==10.1.0 # homeassistant.components.ffmpeg ha-ffmpeg==3.1.0 +# homeassistant.components.iotawatt +ha-iotawattpy==0.1.1 + # homeassistant.components.philips_js ha-philipsjs==3.0.0 @@ -995,9 +998,6 @@ insteon-frontend-home-assistant==0.3.5 # homeassistant.components.intellifire intellifire4py==2.2.2 -# homeassistant.components.iotawatt -iotawattpy==0.1.0 - # homeassistant.components.iperf3 iperf3==0.1.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6ed7a309aa27..ff558c109c60 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -679,6 +679,9 @@ ha-av==10.1.0 # homeassistant.components.ffmpeg ha-ffmpeg==3.1.0 +# homeassistant.components.iotawatt +ha-iotawattpy==0.1.1 + # homeassistant.components.philips_js ha-philipsjs==3.0.0 @@ -766,9 +769,6 @@ insteon-frontend-home-assistant==0.3.5 # homeassistant.components.intellifire intellifire4py==2.2.2 -# homeassistant.components.iotawatt -iotawattpy==0.1.0 - # homeassistant.components.gogogate2 ismartgate==5.0.0 From 9f0d3bfce81601df642f96f1ef43e6cac952f147 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 30 May 2023 19:11:39 -0500 Subject: [PATCH 710/822] Use ReadOnlyDict for entity registry options (#93824) * Use ReadOnlyDict for entity registry options While reviewing #93601 it was noticed this was slow at startup https://github.com/home-assistant/core/pull/93601#issuecomment-1568958280 This is a first pass attempt to improve the performance * fix tests --- homeassistant/helpers/entity_registry.py | 37 +++++++------------ .../snapshots/test_exposed_entities.ambr | 2 +- tests/helpers/test_entity_registry.py | 8 ++-- tests/syrupy.py | 2 +- 4 files changed, 19 insertions(+), 30 deletions(-) diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index d8c5a6c1cf67..b6fc84b9627c 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -12,7 +12,6 @@ from __future__ import annotations from collections import UserDict from collections.abc import Callable, Iterable, Mapping, ValuesView import logging -from types import MappingProxyType from typing import TYPE_CHECKING, Any, TypeVar, cast import attr @@ -44,6 +43,7 @@ from homeassistant.core import ( from homeassistant.exceptions import MaxLengthExceeded from homeassistant.util import slugify, uuid as uuid_util from homeassistant.util.json import format_unserializable_data +from homeassistant.util.read_only_dict import ReadOnlyDict from . import device_registry as dr, storage from .device_registry import EVENT_DEVICE_REGISTRY_UPDATED @@ -102,6 +102,7 @@ class RegistryEntryHider(StrEnum): EntityOptionsType = Mapping[str, Mapping[str, Any]] +ReadOnlyEntityOptionsType = ReadOnlyDict[str, Mapping[str, Any]] DISLAY_DICT_OPTIONAL = ( ("ai", "area_id"), @@ -110,27 +111,13 @@ DISLAY_DICT_OPTIONAL = ( ) -class _EntityOptions(UserDict[str, MappingProxyType]): - """Container for entity options.""" - - def __init__(self, data: Mapping[str, Mapping] | None) -> None: - """Initialize.""" - super().__init__() - if data is None: - return - self.data = {key: MappingProxyType(val) for key, val in data.items()} - - def __setitem__(self, key: str, entry: Mapping) -> None: - """Add an item.""" - raise NotImplementedError - - def __delitem__(self, key: str) -> None: - """Remove an item.""" - raise NotImplementedError - - def as_dict(self) -> dict[str, dict]: - """Return dictionary version.""" - return {key: dict(val) for key, val in self.data.items()} +def _protect_entity_options( + data: EntityOptionsType | None, +) -> ReadOnlyEntityOptionsType: + """Protect entity options from being modified.""" + if data is None: + return ReadOnlyDict({}) + return ReadOnlyDict({key: ReadOnlyDict(val) for key, val in data.items()}) @attr.s(slots=True, frozen=True) @@ -154,7 +141,9 @@ class RegistryEntry: id: str = attr.ib(factory=uuid_util.random_uuid_hex) has_entity_name: bool = attr.ib(default=False) name: str | None = attr.ib(default=None) - options: _EntityOptions = attr.ib(default=None, converter=_EntityOptions) + options: ReadOnlyEntityOptionsType = attr.ib( + default=None, converter=_protect_entity_options + ) # As set by integration original_device_class: str | None = attr.ib(default=None) original_icon: str | None = attr.ib(default=None) @@ -1029,7 +1018,7 @@ class EntityRegistry: "id": entry.id, "has_entity_name": entry.has_entity_name, "name": entry.name, - "options": entry.options.as_dict(), + "options": entry.options, "original_device_class": entry.original_device_class, "original_icon": entry.original_icon, "original_name": entry.original_name, diff --git a/tests/components/homeassistant/snapshots/test_exposed_entities.ambr b/tests/components/homeassistant/snapshots/test_exposed_entities.ambr index 2f9d0b8017f9..55b95186b495 100644 --- a/tests/components/homeassistant/snapshots/test_exposed_entities.ambr +++ b/tests/components/homeassistant/snapshots/test_exposed_entities.ambr @@ -1,7 +1,7 @@ # serializer version: 1 # name: test_get_assistant_settings dict({ - 'climate.test_unique1': mappingproxy({ + 'climate.test_unique1': ReadOnlyDict({ 'should_expose': True, }), 'light.not_in_registry': dict({ diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index e3b91c46e184..a04250657754 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -748,13 +748,13 @@ async def test_update_entity_options(entity_registry: er.EntityRegistry) -> None assert new_entry_1.options == {"light": {"minimum_brightness": 20}} # Test it's not possible to modify the options - with pytest.raises(NotImplementedError): + with pytest.raises(RuntimeError): new_entry_1.options["blah"] = {} - with pytest.raises(NotImplementedError): + with pytest.raises(RuntimeError): new_entry_1.options["light"] = {} - with pytest.raises(TypeError): + with pytest.raises(RuntimeError): new_entry_1.options["light"]["blah"] = 123 - with pytest.raises(TypeError): + with pytest.raises(RuntimeError): new_entry_1.options["light"]["minimum_brightness"] = 123 entity_registry.async_update_entity_options( diff --git a/tests/syrupy.py b/tests/syrupy.py index af34cb628fc1..9433eb1649c4 100644 --- a/tests/syrupy.py +++ b/tests/syrupy.py @@ -170,7 +170,7 @@ class HomeAssistantSnapshotSerializer(AmberDataSerializer): "config_entry_id": ANY, "device_id": ANY, "id": ANY, - "options": data.options.as_dict(), + "options": {k: dict(v) for k, v in data.options.items()}, } ) serialized.pop("_partial_repr") From 3186ddb0952545c92900b274f1b8c228e5fdbfbd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 30 May 2023 19:12:52 -0500 Subject: [PATCH 711/822] Speed up setting up event trackers (#93823) noticed in https://github.com/home-assistant/core/pull/93601 that the cost of creating the function in the closure was a bit expensive since we do it once per entity --- homeassistant/helpers/event.py | 53 ++++++++++++++++++++++------------ 1 file changed, 34 insertions(+), 19 deletions(-) diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index c251f0785a4d..4a169687d80a 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -312,6 +312,25 @@ def _remove_empty_listener() -> None: """Remove a listener that does nothing.""" +@callback +def _remove_listener( + hass: HomeAssistant, + listeners_key: str, + keys: list[str], + job: HassJob[[Event], Any], + callbacks: dict[str, list[HassJob[[Event], Any]]], +) -> None: + """Remove listener.""" + for key in keys: + callbacks[key].remove(job) + if len(callbacks[key]) == 0: + del callbacks[key] + + if not callbacks: + hass.data[listeners_key]() + del hass.data[listeners_key] + + def _async_track_event( hass: HomeAssistant, keys: str | Iterable[str], @@ -333,12 +352,16 @@ def _async_track_event( if isinstance(keys, str): keys = [keys] - callbacks: dict[str, list[HassJob[[Event], Any]]] = hass.data.setdefault( - callbacks_key, {} - ) + hass_data = hass.data - if listeners_key not in hass.data: - hass.data[listeners_key] = hass.bus.async_listen( + callbacks: dict[str, list[HassJob[[Event], Any]]] | None = hass_data.get( + callbacks_key + ) + if not callbacks: + callbacks = hass_data[callbacks_key] = {} + + if listeners_key not in hass_data: + hass_data[listeners_key] = hass.bus.async_listen( event_type, callback(ft.partial(dispatcher_callable, hass, callbacks)), event_filter=callback(ft.partial(filter_callable, hass, callbacks)), @@ -347,21 +370,13 @@ def _async_track_event( job = HassJob(action, f"track {event_type} event {keys}") for key in keys: - callbacks.setdefault(key, []).append(job) + callback_list = callbacks.get(key) + if callback_list: + callback_list.append(job) + else: + callbacks[key] = [job] - @callback - def remove_listener() -> None: - """Remove listener.""" - for key in keys: - callbacks[key].remove(job) - if len(callbacks[key]) == 0: - del callbacks[key] - - if not callbacks: - hass.data[listeners_key]() - del hass.data[listeners_key] - - return remove_listener + return ft.partial(_remove_listener, hass, listeners_key, keys, job, callbacks) @callback From d421a5157625f9523c20900a8ed9452b15939132 Mon Sep 17 00:00:00 2001 From: andarotajo <55669170+andarotajo@users.noreply.github.com> Date: Wed, 31 May 2023 02:22:27 +0200 Subject: [PATCH 712/822] Add coordinator to dwd_weather_warnings (#93720) * Add data coordinator * Specify coordinator data type * Apply code review changes --- .coveragerc | 1 + .../dwd_weather_warnings/__init__.py | 8 ++- .../dwd_weather_warnings/coordinator.py | 26 ++++++++ .../components/dwd_weather_warnings/sensor.py | 62 +++++++------------ 4 files changed, 54 insertions(+), 43 deletions(-) create mode 100644 homeassistant/components/dwd_weather_warnings/coordinator.py diff --git a/.coveragerc b/.coveragerc index 928061407b8c..c244617007fc 100644 --- a/.coveragerc +++ b/.coveragerc @@ -227,6 +227,7 @@ omit = homeassistant/components/dunehd/__init__.py homeassistant/components/dunehd/media_player.py homeassistant/components/dwd_weather_warnings/const.py + homeassistant/components/dwd_weather_warnings/coordinator.py homeassistant/components/dwd_weather_warnings/sensor.py homeassistant/components/dweet/* homeassistant/components/ebox/sensor.py diff --git a/homeassistant/components/dwd_weather_warnings/__init__.py b/homeassistant/components/dwd_weather_warnings/__init__.py index 55309110cafb..275d47d15ca0 100644 --- a/homeassistant/components/dwd_weather_warnings/__init__.py +++ b/homeassistant/components/dwd_weather_warnings/__init__.py @@ -8,16 +8,20 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from .const import CONF_REGION_IDENTIFIER, DOMAIN, PLATFORMS +from .coordinator import DwdWeatherWarningsCoordinator async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" region_identifier: str = entry.data[CONF_REGION_IDENTIFIER] - # Initialize the API. + # Initialize the API and coordinator. api = await hass.async_add_executor_job(DwdWeatherWarningsAPI, region_identifier) + coordinator = DwdWeatherWarningsCoordinator(hass, api) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = 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 diff --git a/homeassistant/components/dwd_weather_warnings/coordinator.py b/homeassistant/components/dwd_weather_warnings/coordinator.py new file mode 100644 index 000000000000..a12326971306 --- /dev/null +++ b/homeassistant/components/dwd_weather_warnings/coordinator.py @@ -0,0 +1,26 @@ +"""Data coordinator for the dwd_weather_warnings integration.""" + +from __future__ import annotations + +from dwdwfsapi import DwdWeatherWarningsAPI + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, LOGGER + + +class DwdWeatherWarningsCoordinator(DataUpdateCoordinator[None]): + """Custom coordinator for the dwd_weather_warnings integration.""" + + def __init__(self, hass: HomeAssistant, api: DwdWeatherWarningsAPI) -> None: + """Initialize the dwd_weather_warnings coordinator.""" + super().__init__( + hass, LOGGER, name=DOMAIN, update_interval=DEFAULT_SCAN_INTERVAL + ) + + self.api = api + + async def _async_update_data(self) -> None: + """Get the latest data from the DWD Weather Warnings API.""" + await self.hass.async_add_executor_job(self.api.update) diff --git a/homeassistant/components/dwd_weather_warnings/sensor.py b/homeassistant/components/dwd_weather_warnings/sensor.py index 26aa0ef1ba07..3e8ed2afbdc6 100644 --- a/homeassistant/components/dwd_weather_warnings/sensor.py +++ b/homeassistant/components/dwd_weather_warnings/sensor.py @@ -27,7 +27,7 @@ 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 import Throttle +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( ADVANCE_WARNING_SENSOR, @@ -48,10 +48,9 @@ from .const import ( CONF_REGION_NAME, CURRENT_WARNING_SENSOR, DEFAULT_NAME, - DEFAULT_SCAN_INTERVAL, DOMAIN, - LOGGER, ) +from .coordinator import DwdWeatherWarningsCoordinator SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( @@ -109,55 +108,60 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up entities from config entry.""" - api = WrappedDwDWWAPI(hass.data[DOMAIN][entry.entry_id]) + coordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities( [ - DwdWeatherWarningsSensor(api, entry, description) + DwdWeatherWarningsSensor(coordinator, entry, description) for description in SENSOR_TYPES ], True, ) -class DwdWeatherWarningsSensor(SensorEntity): +class DwdWeatherWarningsSensor( + CoordinatorEntity[DwdWeatherWarningsCoordinator], SensorEntity +): """Representation of a DWD-Weather-Warnings sensor.""" _attr_attribution = "Data provided by DWD" def __init__( self, - api, + coordinator: DwdWeatherWarningsCoordinator, entry: ConfigEntry, description: SensorEntityDescription, ) -> None: """Initialize a DWD-Weather-Warnings sensor.""" + super().__init__(coordinator) + self.entity_description = description self._attr_name = f"{DEFAULT_NAME} {entry.title} {description.name}" self._attr_unique_id = f"{entry.unique_id}-{description.key}" - self._api = api + + self.api = coordinator.api @property def native_value(self): """Return the state of the sensor.""" if self.entity_description.key == CURRENT_WARNING_SENSOR: - return self._api.api.current_warning_level + return self.api.current_warning_level - return self._api.api.expected_warning_level + return self.api.expected_warning_level @property def extra_state_attributes(self): """Return the state attributes of the sensor.""" data = { - ATTR_REGION_NAME: self._api.api.warncell_name, - ATTR_REGION_ID: self._api.api.warncell_id, - ATTR_LAST_UPDATE: self._api.api.last_update, + ATTR_REGION_NAME: self.api.warncell_name, + ATTR_REGION_ID: self.api.warncell_id, + ATTR_LAST_UPDATE: self.api.last_update, } if self.entity_description.key == CURRENT_WARNING_SENSOR: - searched_warnings = self._api.api.current_warnings + searched_warnings = self.api.current_warnings else: - searched_warnings = self._api.api.expected_warnings + searched_warnings = self.api.expected_warnings data[ATTR_WARNING_COUNT] = len(searched_warnings) @@ -173,7 +177,7 @@ class DwdWeatherWarningsSensor(SensorEntity): data[f"warning_{i}_parameters"] = warning[API_ATTR_WARNING_PARAMETERS] data[f"warning_{i}_color"] = warning[API_ATTR_WARNING_COLOR] - # Dictionary for the attribute containing the complete warning + # Dictionary for the attribute containing the complete warning. warning_copy = warning.copy() warning_copy[API_ATTR_WARNING_START] = data[f"warning_{i}_start"] warning_copy[API_ATTR_WARNING_END] = data[f"warning_{i}_end"] @@ -184,28 +188,4 @@ class DwdWeatherWarningsSensor(SensorEntity): @property def available(self) -> bool: """Could the device be accessed during the last update call.""" - return self._api.api.data_valid - - def update(self) -> None: - """Get the latest data from the DWD-Weather-Warnings API.""" - LOGGER.debug( - "Update requested for %s (%s) by %s", - self._api.api.warncell_name, - self._api.api.warncell_id, - self.entity_description.key, - ) - self._api.update() - - -class WrappedDwDWWAPI: - """Wrapper for the DWD-Weather-Warnings api.""" - - def __init__(self, api): - """Initialize a DWD-Weather-Warnings wrapper.""" - self.api = api - - @Throttle(DEFAULT_SCAN_INTERVAL) - def update(self): - """Get the latest data from the DWD-Weather-Warnings API.""" - self.api.update() - LOGGER.debug("Update performed") + return self.api.data_valid From 3a7f9ab57d4dbcba7ddf05b5f26816c23220f33c Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 31 May 2023 03:07:07 +0200 Subject: [PATCH 713/822] Remove mqtt sensor support for `last_reset_topic` (#93792) * Remove mqtt sensor support for `last_reset_topic` * Add cases for empty last reset messages --- homeassistant/components/mqtt/sensor.py | 59 ++-------- tests/components/mqtt/test_sensor.py | 145 +++++------------------- 2 files changed, 35 insertions(+), 169 deletions(-) diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 9de442926a0c..d63c41964bbb 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -53,7 +53,7 @@ from .models import ( ReceiveMessage, ReceivePayloadType, ) -from .util import get_mqtt_data, valid_subscribe_topic +from .util import get_mqtt_data _LOGGER = logging.getLogger(__name__) @@ -73,36 +73,11 @@ DEFAULT_NAME = "MQTT Sensor" DEFAULT_FORCE_UPDATE = False -def validate_options(conf: ConfigType) -> ConfigType: - """Validate options. - - If last reset topic is present it must be same as the state topic. - """ - if ( - CONF_LAST_RESET_TOPIC in conf - and CONF_STATE_TOPIC in conf - and conf[CONF_LAST_RESET_TOPIC] != conf[CONF_STATE_TOPIC] - ): - _LOGGER.warning( - "'%s' must be same as '%s'", CONF_LAST_RESET_TOPIC, CONF_STATE_TOPIC - ) - - if CONF_LAST_RESET_TOPIC in conf and CONF_LAST_RESET_VALUE_TEMPLATE not in conf: - _LOGGER.warning( - "'%s' must be set if '%s' is set", - CONF_LAST_RESET_VALUE_TEMPLATE, - CONF_LAST_RESET_TOPIC, - ) - - return conf - - _PLATFORM_SCHEMA_BASE = MQTT_RO_SCHEMA.extend( { vol.Optional(CONF_DEVICE_CLASS): vol.Any(DEVICE_CLASSES_SCHEMA, None), vol.Optional(CONF_EXPIRE_AFTER): cv.positive_int, vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, - vol.Optional(CONF_LAST_RESET_TOPIC): valid_subscribe_topic, vol.Optional(CONF_LAST_RESET_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_SUGGESTED_DISPLAY_PRECISION): cv.positive_int, @@ -112,8 +87,10 @@ _PLATFORM_SCHEMA_BASE = MQTT_RO_SCHEMA.extend( ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) PLATFORM_SCHEMA_MODERN = vol.All( + # Deprecated in HA Core 2021.11.0 https://github.com/home-assistant/core/pull/54840 + # Removed in HA Core 2023.6.0 + cv.removed(CONF_LAST_RESET_TOPIC), _PLATFORM_SCHEMA_BASE, - validate_options, ) # Configuring MQTT Sensors under the sensor platform key was deprecated in @@ -123,9 +100,10 @@ PLATFORM_SCHEMA = vol.All( ) DISCOVERY_SCHEMA = vol.All( - cv.deprecated(CONF_LAST_RESET_TOPIC), + # Deprecated in HA Core 2021.11.0 https://github.com/home-assistant/core/pull/54840 + # Removed in HA Core 2023.6.0 + cv.removed(CONF_LAST_RESET_TOPIC), _PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA), - validate_options, ) @@ -319,10 +297,7 @@ class MqttSensor(MqttEntity, RestoreSensor): def message_received(msg: ReceiveMessage) -> None: """Handle new MQTT messages.""" _update_state(msg) - if CONF_LAST_RESET_VALUE_TEMPLATE in self._config and ( - CONF_LAST_RESET_TOPIC not in self._config - or self._config[CONF_LAST_RESET_TOPIC] == self._config[CONF_STATE_TOPIC] - ): + 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) @@ -333,24 +308,6 @@ class MqttSensor(MqttEntity, RestoreSensor): "encoding": self._config[CONF_ENCODING] or None, } - @callback - @log_messages(self.hass, self.entity_id) - def last_reset_message_received(msg: ReceiveMessage) -> None: - """Handle new last_reset messages.""" - _update_last_reset(msg) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - - if ( - CONF_LAST_RESET_TOPIC in self._config - and self._config[CONF_LAST_RESET_TOPIC] != self._config[CONF_STATE_TOPIC] - ): - topics["last_reset_topic"] = { - "topic": self._config[CONF_LAST_RESET_TOPIC], - "msg_callback": last_reset_message_received, - "qos": self._config[CONF_QOS], - "encoding": self._config[CONF_ENCODING] or None, - } - self._sub_state = subscription.async_prepare_subscribe_topics( self.hass, self._sub_state, topics ) diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 64499f11140d..16697939f50e 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -480,117 +480,44 @@ async def test_setting_sensor_value_via_mqtt_json_message_and_default_current_st "state_class": "total", "state_topic": "test-topic", "unit_of_measurement": "fav unit", - "last_reset_topic": "last-reset-topic", - } - } - } - ], -) -async def test_setting_sensor_last_reset_via_mqtt_message( - hass: HomeAssistant, - mqtt_mock_entry: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the setting of the last_reset property via MQTT.""" - await mqtt_mock_entry() - - async_fire_mqtt_message(hass, "last-reset-topic", "2020-01-02 08:11:00") - state = hass.states.get("sensor.test") - assert state.attributes.get("last_reset") == "2020-01-02T08:11:00" - assert "'last_reset_topic' must be same as 'state_topic'" in caplog.text - assert ( - "'last_reset_value_template' must be set if 'last_reset_topic' is set" - in caplog.text - ) - - -@pytest.mark.parametrize( - "hass_config", - [ - { - mqtt.DOMAIN: { - sensor.DOMAIN: { - "name": "test", - "state_class": "total", - "state_topic": "test-topic", - "unit_of_measurement": "fav unit", - "last_reset_topic": "last-reset-topic", - } - } - } - ], -) -@pytest.mark.parametrize("datestring", ["2020-21-02 08:11:00", "Hello there!"]) -async def test_setting_sensor_bad_last_reset_via_mqtt_message( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - datestring, - mqtt_mock_entry: MqttMockHAClientGenerator, -) -> None: - """Test the setting of the last_reset property via MQTT.""" - await mqtt_mock_entry() - - async_fire_mqtt_message(hass, "last-reset-topic", datestring) - state = hass.states.get("sensor.test") - assert state.attributes.get("last_reset") is None - assert "Invalid last_reset message" in caplog.text - - -@pytest.mark.parametrize( - "hass_config", - [ - { - mqtt.DOMAIN: { - sensor.DOMAIN: { - "name": "test", - "state_class": "total", - "state_topic": "test-topic", - "unit_of_measurement": "fav unit", - "last_reset_topic": "last-reset-topic", - } - } - } - ], -) -async def test_setting_sensor_empty_last_reset_via_mqtt_message( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test the setting of the last_reset property via MQTT.""" - await mqtt_mock_entry() - - async_fire_mqtt_message(hass, "last-reset-topic", "") - state = hass.states.get("sensor.test") - assert state.attributes.get("last_reset") is None - - -@pytest.mark.parametrize( - "hass_config", - [ - { - mqtt.DOMAIN: { - sensor.DOMAIN: { - "name": "test", - "state_class": "total", - "state_topic": "test-topic", - "unit_of_measurement": "fav unit", - "last_reset_topic": "last-reset-topic", "last_reset_value_template": "{{ value_json.last_reset }}", + "value_template": "{{ value_json.state }}", } } } ], ) +@pytest.mark.parametrize( + ("message", "last_reset", "state"), + [ + ( + '{ "last_reset": "2020-01-02 08:11:00" }', + "2020-01-02T08:11:00", + STATE_UNKNOWN, + ), + ( + '{ "last_reset": "2020-01-02 08:11:03", "state": 10.0 }', + "2020-01-02T08:11:03", + "10.0", + ), + ('{ "last_reset": null, "state": 10.1 }', None, "10.1"), + ('{ "last_reset": "", "state": 10.1 }', None, "10.1"), + ], +) async def test_setting_sensor_last_reset_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + message: str, + last_reset: str, + state: str, ) -> None: """Test the setting of the value via MQTT with JSON payload.""" await mqtt_mock_entry() - async_fire_mqtt_message( - hass, "last-reset-topic", '{ "last_reset": "2020-01-02 08:11:00" }' - ) - state = hass.states.get("sensor.test") - assert state.attributes.get("last_reset") == "2020-01-02T08:11:00" + async_fire_mqtt_message(hass, "test-topic", message) + sensor_state = hass.states.get("sensor.test") + assert sensor_state.attributes.get("last_reset") == last_reset + assert sensor_state.state == state @pytest.mark.parametrize( @@ -608,19 +535,6 @@ async def test_setting_sensor_last_reset_via_mqtt_json_message( }, } }, - { - mqtt.DOMAIN: { - sensor.DOMAIN: { - "name": "test", - "state_class": "total", - "state_topic": "test-topic", - "unit_of_measurement": "kWh", - "value_template": "{{ value_json.value | float / 60000 }}", - "last_reset_value_template": "{{ utcnow().fromtimestamp(value_json.time / 1000, tz=utcnow().tzinfo) }}", - "last_reset_topic": "test-topic", - }, - } - }, ], ) async def test_setting_sensor_last_reset_via_mqtt_json_message_2( @@ -640,11 +554,6 @@ async def test_setting_sensor_last_reset_via_mqtt_json_message_2( state = hass.states.get("sensor.test") assert float(state.state) == pytest.approx(0.015796176944444445) assert state.attributes.get("last_reset") == "2021-08-19T15:05:00+00:00" - assert "'last_reset_topic' must be same as 'state_topic'" not in caplog.text - assert ( - "'last_reset_value_template' must be set if 'last_reset_topic' is set" - not in caplog.text - ) @pytest.mark.parametrize( From 049582ec50b731e7f3d7049c325ef040a052a5c9 Mon Sep 17 00:00:00 2001 From: Luke Date: Tue, 30 May 2023 21:10:28 -0400 Subject: [PATCH 714/822] Add Switch platform to Roborock (#93833) * init * add switch platform * remove stale comments * remove stale list * set entity category to config --- homeassistant/components/roborock/const.py | 2 +- .../components/roborock/strings.json | 5 + homeassistant/components/roborock/switch.py | 110 ++++++++++++++++++ tests/components/roborock/conftest.py | 2 + tests/components/roborock/test_switch.py | 67 +++++++++++ 5 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/roborock/switch.py create mode 100644 tests/components/roborock/test_switch.py diff --git a/homeassistant/components/roborock/const.py b/homeassistant/components/roborock/const.py index 5fb0f888eadf..287229c9fd1d 100644 --- a/homeassistant/components/roborock/const.py +++ b/homeassistant/components/roborock/const.py @@ -6,4 +6,4 @@ CONF_ENTRY_CODE = "code" CONF_BASE_URL = "base_url" CONF_USER_DATA = "user_data" -PLATFORMS = [Platform.VACUUM, Platform.SELECT, Platform.SENSOR] +PLATFORMS = [Platform.VACUUM, Platform.SELECT, Platform.SENSOR, Platform.SWITCH] diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index f8b8dae38d2f..17b9b12cab76 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -97,6 +97,11 @@ } } }, + "switch": { + "child_lock": { + "name": "Child lock" + } + }, "vacuum": { "roborock": { "state_attributes": { diff --git a/homeassistant/components/roborock/switch.py b/homeassistant/components/roborock/switch.py new file mode 100644 index 000000000000..7b675c3be384 --- /dev/null +++ b/homeassistant/components/roborock/switch.py @@ -0,0 +1,110 @@ +"""Support for Roborock switch.""" +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +import logging +from typing import Any + +from roborock.roborock_typing import RoborockCommand + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +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 + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class RoborockSwitchDescriptionMixin: + """Define an entity description mixin for switch entities.""" + + # Gets the status of the switch + get_value: Callable[[RoborockCoordinatedEntity], Coroutine[Any, Any, dict]] + # Evaluate the result of get_value to determine a bool + evaluate_value: Callable[[dict], bool] + # Sets the status of the switch + set_command: Callable[[RoborockCoordinatedEntity, bool], Coroutine[Any, Any, dict]] + + +@dataclass +class RoborockSwitchDescription( + SwitchEntityDescription, RoborockSwitchDescriptionMixin +): + """Class to describe an Roborock switch entity.""" + + +SWITCH_DESCRIPTIONS: list[RoborockSwitchDescription] = [ + RoborockSwitchDescription( + set_command=lambda entity, value: entity.send( + RoborockCommand.SET_CHILD_LOCK_STATUS, {"lock_status": 1 if value else 0} + ), + get_value=lambda data: data.send(RoborockCommand.GET_CHILD_LOCK_STATUS), + evaluate_value=lambda data: data["lock_status"] == 1, + key="child_lock", + translation_key="child_lock", + icon="mdi:account-lock", + entity_category=EntityCategory.CONFIG, + ) +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Roborock switch platform.""" + + coordinators: dict[str, RoborockDataUpdateCoordinator] = hass.data[DOMAIN][ + config_entry.entry_id + ] + async_add_entities( + ( + RoborockSwitchEntity( + f"{description.key}_{slugify(device_id)}", + coordinator, + description, + ) + for device_id, coordinator in coordinators.items() + for description in SWITCH_DESCRIPTIONS + ), + True, + ) + + +class RoborockSwitchEntity(RoborockCoordinatedEntity, SwitchEntity): + """A class to let you turn functionality on Roborock devices on and off.""" + + entity_description: RoborockSwitchDescription + + def __init__( + self, + unique_id: str, + coordinator: RoborockDataUpdateCoordinator, + entity_description: RoborockSwitchDescription, + ) -> None: + """Create a switch entity.""" + self.entity_description = entity_description + super().__init__(unique_id, coordinator) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the switch.""" + await self.entity_description.set_command(self, False) + return self.async_schedule_update_ha_state(True) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the switch.""" + await self.entity_description.set_command(self, True) + return self.async_schedule_update_ha_state(True) + + async def async_update(self) -> None: + """Update switch.""" + self._attr_is_on = self.entity_description.evaluate_value( + await self.entity_description.get_value(self) + ) diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index f95436474e80..b311f84f94a0 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -58,6 +58,8 @@ async def setup_entry( ), patch( "homeassistant.components.roborock.coordinator.RoborockLocalClient.get_prop", return_value=PROP, + ), patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClient.send_message" ): assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() diff --git a/tests/components/roborock/test_switch.py b/tests/components/roborock/test_switch.py new file mode 100644 index 000000000000..153ddbd8293e --- /dev/null +++ b/tests/components/roborock/test_switch.py @@ -0,0 +1,67 @@ +"""Test Roborock Switch platform.""" +from unittest.mock import patch + +import pytest +from roborock.exceptions import RoborockException + +from homeassistant.components.switch import SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("entity_id"), + [ + ("switch.roborock_s7_maxv_child_lock"), + ], +) +async def test_update_success( + hass: HomeAssistant, + bypass_api_fixture, + setup_entry: MockConfigEntry, + entity_id: str, +) -> None: + """Test turning switch entities on and off.""" + with patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClient.send_message" + ) as mock_send_message: + await hass.services.async_call( + "switch", + SERVICE_TURN_ON, + service_data=None, + blocking=True, + target={"entity_id": entity_id}, + ) + assert mock_send_message.assert_called_once + with patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClient.send_message" + ) as mock_send_message: + await hass.services.async_call( + "switch", + SERVICE_TURN_OFF, + service_data=None, + blocking=True, + target={"entity_id": entity_id}, + ) + assert mock_send_message.assert_called_once + + +async def test_update_failure( + hass: HomeAssistant, + bypass_api_fixture, + setup_entry: MockConfigEntry, +) -> None: + """Test that changing a value will raise a homeassistanterror when it fails.""" + with patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClient.send_message", + side_effect=RoborockException(), + ), pytest.raises(HomeAssistantError): + await hass.services.async_call( + "switch", + SERVICE_TURN_ON, + service_data=None, + blocking=True, + target={"entity_id": "switch.roborock_s7_maxv_child_lock"}, + ) From 466eec2f04bfe59fa643aeee353fdf71be40055f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 30 May 2023 20:13:49 -0500 Subject: [PATCH 715/822] Add a debounce to ld2410 to prevent it from overwhelming the state machine with many devices (#93819) * Add a debounce to ld2410 to prevent it from overwhelming the state machine fixes #86665 * set time on debounce --- .../components/ld2410_ble/__init__.py | 5 ++- .../components/ld2410_ble/coordinator.py | 38 ++++++++++++++++++- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ld2410_ble/__init__.py b/homeassistant/components/ld2410_ble/__init__.py index 204a5367e0b9..e127a4a98361 100644 --- a/homeassistant/components/ld2410_ble/__init__.py +++ b/homeassistant/components/ld2410_ble/__init__.py @@ -2,7 +2,7 @@ import logging -from bleak_retry_connector import BleakError, get_device +from bleak_retry_connector import BleakError, close_stale_connections, get_device from ld2410_ble import LD2410BLE from homeassistant.components import bluetooth @@ -31,6 +31,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady( f"Could not find LD2410B device with address {address}" ) + + await close_stale_connections(ble_device) + ld2410_ble = LD2410BLE(ble_device) coordinator = LD2410BLECoordinator(hass, ld2410_ble) diff --git a/homeassistant/components/ld2410_ble/coordinator.py b/homeassistant/components/ld2410_ble/coordinator.py index 6ab255530940..2f0fd0797733 100644 --- a/homeassistant/components/ld2410_ble/coordinator.py +++ b/homeassistant/components/ld2410_ble/coordinator.py @@ -1,16 +1,22 @@ """Data coordinator for receiving LD2410B updates.""" +from datetime import datetime import logging +import time from ld2410_ble import LD2410BLE, LD2410BLEState -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback +from homeassistant.helpers.event import async_call_later from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +NEVER_TIME = -86400.0 +DEBOUNCE_SECONDS = 1.0 + class LD2410BLECoordinator(DataUpdateCoordinator[None]): """Data coordinator for receiving LD2410B updates.""" @@ -26,15 +32,43 @@ class LD2410BLECoordinator(DataUpdateCoordinator[None]): ld2410_ble.register_callback(self._async_handle_update) ld2410_ble.register_disconnected_callback(self._async_handle_disconnect) self.connected = False + self._last_update_time = NEVER_TIME + self._debounce_cancel: CALLBACK_TYPE | None = None + self._debounced_update_job = HassJob( + self._async_handle_debounced_update, + f"LD2410 {ld2410_ble.address} BLE debounced update", + ) + + @callback + def _async_handle_debounced_update(self, _now: datetime) -> None: + """Handle debounced update.""" + self._debounce_cancel = None + self._last_update_time = time.monotonic() + self.async_set_updated_data(None) @callback def _async_handle_update(self, state: LD2410BLEState) -> None: """Just trigger the callbacks.""" self.connected = True - self.async_set_updated_data(None) + previous_last_updated_time = self._last_update_time + self._last_update_time = time.monotonic() + if self._last_update_time - previous_last_updated_time >= DEBOUNCE_SECONDS: + self.async_set_updated_data(None) + return + if self._debounce_cancel is None: + self._debounce_cancel = async_call_later( + self.hass, DEBOUNCE_SECONDS, self._debounced_update_job + ) @callback def _async_handle_disconnect(self) -> None: """Trigger the callbacks for disconnected.""" self.connected = False self.async_update_listeners() + + async def async_shutdown(self) -> None: + """Shutdown the coordinator.""" + if self._debounce_cancel is not None: + self._debounce_cancel() + self._debounce_cancel = None + await super().async_shutdown() From 5e1c9ba80da558fcac0a25b4c6e8379763e62de6 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 31 May 2023 03:22:11 +0200 Subject: [PATCH 716/822] Translate entity names for Yale Smart Living (#93560) --- .../yale_smart_alarm/binary_sensor.py | 8 +++---- .../components/yale_smart_alarm/button.py | 2 +- .../components/yale_smart_alarm/strings.json | 21 +++++++++++++++++++ 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/yale_smart_alarm/binary_sensor.py b/homeassistant/components/yale_smart_alarm/binary_sensor.py index 49cd10f4c64b..2aad449a3f78 100644 --- a/homeassistant/components/yale_smart_alarm/binary_sensor.py +++ b/homeassistant/components/yale_smart_alarm/binary_sensor.py @@ -20,25 +20,25 @@ SENSOR_TYPES = ( key="acfail", device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, - name="Power loss", + translation_key="power_loss", ), BinarySensorEntityDescription( key="battery", device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, - name="Battery", + translation_key="battery", ), BinarySensorEntityDescription( key="tamper", device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, - name="Tamper", + translation_key="tamper", ), BinarySensorEntityDescription( key="jam", device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, - name="Jam", + translation_key="jam", ), ) diff --git a/homeassistant/components/yale_smart_alarm/button.py b/homeassistant/components/yale_smart_alarm/button.py index d8601ec85f9a..901cd1863eec 100644 --- a/homeassistant/components/yale_smart_alarm/button.py +++ b/homeassistant/components/yale_smart_alarm/button.py @@ -15,7 +15,7 @@ from .entity import YaleAlarmEntity BUTTON_TYPES = ( ButtonEntityDescription( key="panic", - name="Panic button", + translation_key="panic", icon="mdi:alarm-light", ), ) diff --git a/homeassistant/components/yale_smart_alarm/strings.json b/homeassistant/components/yale_smart_alarm/strings.json index 6a6443c1b9b6..5928013e0982 100644 --- a/homeassistant/components/yale_smart_alarm/strings.json +++ b/homeassistant/components/yale_smart_alarm/strings.json @@ -39,5 +39,26 @@ "error": { "code_format_mismatch": "The code does not match the required number of digits" } + }, + "entity": { + "binary_sensor": { + "power_loss": { + "name": "Power loss" + }, + "battery": { + "name": "Battery" + }, + "tamper": { + "name": "Tamper" + }, + "jam": { + "name": "Jam" + } + }, + "button": { + "panic": { + "name": "Panic button" + } + } } } From 6736ed67ba83491a66555e1073d407c5ba086007 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 31 May 2023 03:23:47 +0200 Subject: [PATCH 717/822] Translate config flow for sensor measurement option in group (#87374) * Translate config flow * reset en * Delete en.json --- homeassistant/components/group/config_flow.py | 24 +++++++++++-------- homeassistant/components/group/strings.json | 14 +++++++++++ 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/group/config_flow.py b/homeassistant/components/group/config_flow.py index 53a8fd062641..6cdc47f9e856 100644 --- a/homeassistant/components/group/config_flow.py +++ b/homeassistant/components/group/config_flow.py @@ -24,14 +24,14 @@ from .binary_sensor import CONF_ALL from .const import CONF_HIDE_MEMBERS, CONF_IGNORE_NON_NUMERIC _STATISTIC_MEASURES = [ - selector.SelectOptionDict(value="min", label="Minimum"), - selector.SelectOptionDict(value="max", label="Maximum"), - selector.SelectOptionDict(value="mean", label="Arithmetic mean"), - selector.SelectOptionDict(value="median", label="Median"), - selector.SelectOptionDict(value="last", label="Most recently updated"), - selector.SelectOptionDict(value="range", label="Statistical range"), - selector.SelectOptionDict(value="sum", label="Sum"), - selector.SelectOptionDict(value="product", label="Product"), + "min", + "max", + "mean", + "median", + "last", + "range", + "sum", + "product", ] @@ -80,13 +80,17 @@ BINARY_SENSOR_CONFIG_SCHEMA = basic_group_config_schema("binary_sensor").extend( SENSOR_CONFIG_EXTENDS = { vol.Required(CONF_TYPE): selector.SelectSelector( - selector.SelectSelectorConfig(options=_STATISTIC_MEASURES), + selector.SelectSelectorConfig( + options=_STATISTIC_MEASURES, translation_key=CONF_TYPE + ), ), } SENSOR_OPTIONS = { vol.Optional(CONF_IGNORE_NON_NUMERIC, default=False): selector.BooleanSelector(), vol.Required(CONF_TYPE): selector.SelectSelector( - selector.SelectSelectorConfig(options=_STATISTIC_MEASURES), + selector.SelectSelectorConfig( + options=_STATISTIC_MEASURES, translation_key=CONF_TYPE + ), ), } diff --git a/homeassistant/components/group/strings.json b/homeassistant/components/group/strings.json index 9f5054546812..192823cef651 100644 --- a/homeassistant/components/group/strings.json +++ b/homeassistant/components/group/strings.json @@ -176,5 +176,19 @@ } } } + }, + "selector": { + "type": { + "options": { + "min": "Minimum", + "max": "Maximum", + "mean": "Arithmetic mean", + "median": "Median", + "last": "Most recently updated", + "range": "Statistical range", + "sum": "Sum", + "product": "Product" + } + } } } From ac8d8dccd2ad473ab0c8177bd56d6bb908779871 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 31 May 2023 03:25:31 +0200 Subject: [PATCH 718/822] Add translation for Trafikverket Weatherstation sensor (#87315) * Trafikverket weatherstation translate sensor * Fix enums --- .../trafikverket_weatherstation/sensor.py | 54 +++++++++++++++++-- .../trafikverket_weatherstation/strings.json | 48 +++++++++++++++++ 2 files changed, 99 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/trafikverket_weatherstation/sensor.py b/homeassistant/components/trafikverket_weatherstation/sensor.py index 6dfd6a5e7c40..8523ded1fffc 100644 --- a/homeassistant/components/trafikverket_weatherstation/sensor.py +++ b/homeassistant/components/trafikverket_weatherstation/sensor.py @@ -30,6 +30,45 @@ from homeassistant.util.dt import as_utc from .const import ATTRIBUTION, CONF_STATION, DOMAIN, NONE_IS_ZERO_SENSORS from .coordinator import TVDataUpdateCoordinator +WIND_DIRECTIONS = [ + "east", + "north_east", + "east_south_east", + "north", + "north_north_east", + "north_north_west", + "north_west", + "south", + "south_east", + "south_south_west", + "south_west", + "west", +] +PRECIPITATION_AMOUNTNAME = [ + "error", + "mild_rain", + "moderate_rain", + "heavy_rain", + "mild_snow_rain", + "moderate_snow_rain", + "heavy_snow_rain", + "mild_snow", + "moderate_snow", + "heavy_snow", + "other", + "none", + "error", +] +PRECIPITATION_TYPE = [ + "drizzle", + "hail", + "none", + "rain", + "snow", + "rain_snow_mixed", + "freezing_rain", +] + @dataclass class TrafikverketRequiredKeysMixin: @@ -64,10 +103,13 @@ SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = ( ), TrafikverketSensorEntityDescription( key="precipitation", - api_key="precipitationtype", + api_key="precipitationtype_translated", name="Precipitation type", icon="mdi:weather-snowy-rainy", entity_registry_enabled_default=False, + translation_key="precipitation", + options=PRECIPITATION_TYPE, + device_class=SensorDeviceClass.ENUM, ), TrafikverketSensorEntityDescription( key="wind_direction", @@ -79,9 +121,12 @@ SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = ( ), TrafikverketSensorEntityDescription( key="wind_direction_text", - api_key="winddirectiontext", + api_key="winddirectiontext_translated", name="Wind direction text", icon="mdi:flag-triangle", + translation_key="wind_direction_text", + options=WIND_DIRECTIONS, + device_class=SensorDeviceClass.ENUM, ), TrafikverketSensorEntityDescription( key="wind_speed", @@ -121,10 +166,13 @@ SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = ( ), TrafikverketSensorEntityDescription( key="precipitation_amountname", - api_key="precipitation_amountname", + api_key="precipitation_amountname_translated", name="Precipitation name", icon="mdi:weather-pouring", entity_registry_enabled_default=False, + translation_key="precipitation_amountname", + options=PRECIPITATION_AMOUNTNAME, + device_class=SensorDeviceClass.ENUM, ), TrafikverketSensorEntityDescription( key="measure_time", diff --git a/homeassistant/components/trafikverket_weatherstation/strings.json b/homeassistant/components/trafikverket_weatherstation/strings.json index 1ac4bbed01ea..3680fae6d8c6 100644 --- a/homeassistant/components/trafikverket_weatherstation/strings.json +++ b/homeassistant/components/trafikverket_weatherstation/strings.json @@ -17,5 +17,53 @@ } } } + }, + "entity": { + "sensor": { + "wind_direction_text": { + "state": { + "east": "East", + "north_east": "North east", + "east_south_east": "East-south east", + "north": "North", + "north_north_east": "North-north east", + "north_north_west": "North-north west", + "north_west": "North west", + "south": "South", + "south_east": "South east", + "south_south_west": "South-south west", + "south_west": "South west", + "west": "West" + } + }, + "precipitation_amountname": { + "state": { + "error": "Error", + "mild_rain": "Mild rain", + "moderate_rain": "Moderate rain", + "heavy_rain": "Heavy rain", + "mild_snow_rain": "Mild rain and snow mixed", + "moderate_snow_rain": "Moderate rain and snow mixed", + "heavy_snow_rain": "Heavy rain and snow mixed", + "mild_snow": "Mild snow", + "moderate_snow": "Moderate snow", + "heavy_snow": "Heavy snow", + "other": "Other", + "none": "None", + "unknown": "Unknown" + } + }, + "precipitation": { + "state": { + "drizzle": "Drizzle", + "hail": "Hail", + "none": "None", + "rain": "Rain", + "snow": "Snow", + "rain_snow_mixed": "Rain and snow mixed", + "freezing_rain": "Freezing rain" + } + } + } } } From f6d3b0618e094ea8693ab176e50ef1a10f665141 Mon Sep 17 00:00:00 2001 From: Dominik Date: Wed, 31 May 2023 03:35:33 +0200 Subject: [PATCH 719/822] Glances: Add error handling for invalid sensor data (#93542) * Set sensor to Unavailable when native_value is invalid * Add unit tests for sensors --- homeassistant/components/glances/sensor.py | 12 ++++++++++ tests/components/glances/__init__.py | 26 ++++++++++++++++++++-- tests/components/glances/test_sensor.py | 13 ++++++++++- 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/glances/sensor.py b/homeassistant/components/glances/sensor.py index 8b836fba3eaa..4d2546dac587 100644 --- a/homeassistant/components/glances/sensor.py +++ b/homeassistant/components/glances/sensor.py @@ -328,6 +328,18 @@ class GlancesSensor(CoordinatorEntity[GlancesDataUpdateCoordinator], SensorEntit ) self._attr_unique_id = f"{coordinator.config_entry.entry_id}-{sensor_name_prefix}-{description.key}" + @property + def available(self) -> bool: + """Set sensor unavailable when native value is invalid.""" + if super().available: + return ( + not self._numeric_state_expected + or isinstance(self.native_value, (int, float)) + or isinstance(self.native_value, str) + and self.native_value.isnumeric() + ) + return False + @property def native_value(self) -> StateType: """Return the state of the resources.""" diff --git a/tests/components/glances/__init__.py b/tests/components/glances/__init__.py index 8c9394ae84f7..064c5ab0eb5a 100644 --- a/tests/components/glances/__init__.py +++ b/tests/components/glances/__init__.py @@ -109,7 +109,25 @@ MOCK_DATA = { "unit": "C", "type": "temperature_core", "key": "label", - } + }, + { + "label": "err_temp", + "value": "ERR", + "warning": None, + "critical": None, + "unit": "C", + "type": "temperature_hdd", + "key": "label", + }, + { + "label": "na_temp", + "value": "NA", + "warning": None, + "critical": None, + "unit": "C", + "type": "temperature_hdd", + "key": "label", + }, ], "system": { "os_name": "Linux", @@ -127,7 +145,11 @@ HA_SENSOR_DATA: dict[str, Any] = { "/ssl": {"disk_use": 30.7, "disk_use_percent": 6.7, "disk_free": 426.5}, "/media": {"disk_use": 30.7, "disk_use_percent": 6.7, "disk_free": 426.5}, }, - "sensors": {"cpu_thermal 1": {"temperature_core": 59}}, + "sensors": { + "cpu_thermal 1": {"temperature_core": 59}, + "err_temp": {"temperature_hdd": "Unavailable"}, + "na_temp": {"temperature_hdd": "Unavailable"}, + }, "mem": { "memory_use_percent": 27.6, "memory_use": 1047.1, diff --git a/tests/components/glances/test_sensor.py b/tests/components/glances/test_sensor.py index 9164dbadb146..2366e10d11b7 100644 --- a/tests/components/glances/test_sensor.py +++ b/tests/components/glances/test_sensor.py @@ -21,9 +21,20 @@ async def test_sensor_states(hass: HomeAssistant) -> None: if state := hass.states.get("sensor.0_0_0_0_ssl_disk_use"): assert state.state == HA_SENSOR_DATA["fs"]["/ssl"]["disk_use"] - if state := hass.states.get("sensor.0_0_0_0_cpu_thermal_1"): assert state.state == HA_SENSOR_DATA["sensors"]["cpu_thermal 1"] + if state := hass.states.get("sensor.0_0_0_0_err_temp"): + assert state.state == HA_SENSOR_DATA["sensors"]["err_temp"] + if state := hass.states.get("sensor.0_0_0_0_na_temp"): + assert state.state == HA_SENSOR_DATA["sensors"]["na_temp"] + if state := hass.states.get("sensor.0_0_0_0_memory_use_percent"): + assert state.state == HA_SENSOR_DATA["mem"]["memory_use_percent"] + if state := hass.states.get("sensor.0_0_0_0_docker_active"): + assert state.state == HA_SENSOR_DATA["docker"]["docker_active"] + if state := hass.states.get("sensor.0_0_0_0_docker_cpu_use"): + assert state.state == HA_SENSOR_DATA["docker"]["docker_cpu_use"] + if state := hass.states.get("sensor.0_0_0_0_docker_memory_use"): + assert state.state == HA_SENSOR_DATA["docker"]["docker_memory_use"] @pytest.mark.parametrize( From 9eac0458dddb810a4f30301846866eef4d1ad8c3 Mon Sep 17 00:00:00 2001 From: Emory Penney Date: Tue, 30 May 2023 18:40:59 -0700 Subject: [PATCH 720/822] Obihai to have common unique_ids with DHCP or without (#91239) * DHCPInfo and get_mac_address case mismatch * Switch to format_mac * Run black --- homeassistant/components/obihai/__init__.py | 5 +++-- homeassistant/components/obihai/config_flow.py | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/obihai/__init__.py b/homeassistant/components/obihai/__init__.py index 12cb9e25f849..82e42b67586b 100644 --- a/homeassistant/components/obihai/__init__.py +++ b/homeassistant/components/obihai/__init__.py @@ -3,6 +3,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import format_mac from .connectivity import ObihaiConnection from .const import LOGGER, PLATFORMS @@ -29,10 +30,10 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) await hass.async_add_executor_job(requester.update) - new_unique_id = await hass.async_add_executor_job( + device_mac = await hass.async_add_executor_job( requester.pyobihai.get_device_mac ) - hass.config_entries.async_update_entry(entry, unique_id=new_unique_id) + hass.config_entries.async_update_entry(entry, unique_id=format_mac(device_mac)) entry.version = 2 diff --git a/homeassistant/components/obihai/config_flow.py b/homeassistant/components/obihai/config_flow.py index ce5cca6a7a4f..1790add84f07 100644 --- a/homeassistant/components/obihai/config_flow.py +++ b/homeassistant/components/obihai/config_flow.py @@ -13,6 +13,7 @@ from homeassistant.config_entries import ConfigFlow from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.device_registry import format_mac from .connectivity import validate_auth from .const import DEFAULT_PASSWORD, DEFAULT_USERNAME, DOMAIN @@ -77,7 +78,7 @@ class ObihaiFlowHandler(ConfigFlow, domain=DOMAIN): device_mac = await self.hass.async_add_executor_job( pyobihai.get_device_mac ) - await self.async_set_unique_id(device_mac) + await self.async_set_unique_id(format_mac(device_mac)) self._abort_if_unique_id_configured() return self.async_create_entry( @@ -104,7 +105,7 @@ class ObihaiFlowHandler(ConfigFlow, domain=DOMAIN): ) -> FlowResult: """Attempt to confirm.""" assert self._dhcp_discovery_info - await self.async_set_unique_id(self._dhcp_discovery_info.macaddress) + await self.async_set_unique_id(format_mac(self._dhcp_discovery_info.macaddress)) self._abort_if_unique_id_configured() if user_input is None: From b91c6911d9fde75c9309157e44dba28ec7eedff3 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 31 May 2023 03:42:58 +0200 Subject: [PATCH 721/822] Reolink autotrack streams for TrackMix and high/low resolution snapshots (#90591) * Add auto track stream for Trackmix connected to NVR * Enable autotracking stream by default * Add snapshot high/low resolution * Improve naming * fix snapshots camera's not beeing added * keep unique ID the same * fix styling --- homeassistant/components/reolink/camera.py | 23 +++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/reolink/camera.py b/homeassistant/components/reolink/camera.py index a34f8c85d36c..b012649ec4c5 100644 --- a/homeassistant/components/reolink/camera.py +++ b/homeassistant/components/reolink/camera.py @@ -28,13 +28,18 @@ async def async_setup_entry( cameras = [] for channel in host.api.stream_channels: - streams = ["sub", "main", "snapshots"] + streams = ["sub", "main", "snapshots_sub", "snapshots_main"] if host.api.protocol in ["rtmp", "flv"]: streams.append("ext") + if host.api.supported(channel, "autotrack_stream"): + streams.extend( + ["autotrack_sub", "autotrack_snapshots_sub", "autotrack_snapshots_main"] + ) + for stream in streams: stream_url = await host.api.get_stream_source(channel, stream) - if stream_url is None and stream != "snapshots": + if stream_url is None and "snapshots" not in stream: continue cameras.append(ReolinkCamera(reolink_data, channel, stream)) @@ -58,12 +63,16 @@ class ReolinkCamera(ReolinkChannelCoordinatorEntity, Camera): self._stream = stream + stream_name = self._stream.replace("_", " ") if self._host.api.model in DUAL_LENS_MODELS: - self._attr_name = f"{self._stream} lens {self._channel}" + self._attr_name = f"{stream_name} lens {self._channel}" else: - self._attr_name = self._stream - self._attr_unique_id = f"{self._host.unique_id}_{self._channel}_{self._stream}" - self._attr_entity_registry_enabled_default = stream == "sub" + self._attr_name = stream_name + stream_id = self._stream + if stream_id == "snapshots_main": + stream_id = "snapshots" + self._attr_unique_id = f"{self._host.unique_id}_{self._channel}_{stream_id}" + self._attr_entity_registry_enabled_default = stream in ["sub", "autotrack_sub"] async def stream_source(self) -> str | None: """Return the source of the stream.""" @@ -73,4 +82,4 @@ class ReolinkCamera(ReolinkChannelCoordinatorEntity, Camera): self, width: int | None = None, height: int | None = None ) -> bytes | None: """Return a still image response from the camera.""" - return await self._host.api.get_snapshot(self._channel) + return await self._host.api.get_snapshot(self._channel, self._stream) From fba826ae9ebdbdb6c116ec5a69ed8ae252d153b6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 30 May 2023 20:48:17 -0500 Subject: [PATCH 722/822] Migrate restore_state helper to use registry loading pattern (#93773) * Migrate restore_state helper to use registry loading pattern As more entities have started using restore_state over time, it has become a startup bottleneck as each entity being added is creating a task to load restore state data that is already loaded since it is a singleton We now use the same pattern as the registry helpers * fix refactoring error -- guess I am tired * fixes * fix tests * fix more * fix more * fix zha tests * fix zha tests * comments * fix error * add missing coverage * s/DATA_RESTORE_STATE_TASK/DATA_RESTORE_STATE/g --- homeassistant/bootstrap.py | 2 + homeassistant/helpers/restore_state.py | 84 +++++++++++----------- tests/common.py | 35 ++++++++- tests/components/number/test_init.py | 7 +- tests/components/sensor/test_init.py | 7 +- tests/components/text/test_init.py | 7 +- tests/components/timer/test_init.py | 34 +++------ tests/components/zha/test_binary_sensor.py | 5 ++ tests/components/zha/test_select.py | 3 + tests/components/zha/test_sensor.py | 4 ++ tests/helpers/test_restore_state.py | 45 ++++++++---- 11 files changed, 147 insertions(+), 86 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 67b62da94d56..7e5aa853f121 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -32,6 +32,7 @@ from .helpers import ( entity_registry, issue_registry, recorder, + restore_state, template, ) from .helpers.dispatcher import async_dispatcher_send @@ -248,6 +249,7 @@ async def load_registries(hass: core.HomeAssistant) -> None: issue_registry.async_load(hass), hass.async_add_executor_job(_cache_uname_processor), template.async_load_custom_templates(hass), + restore_state.async_load(hass), ) diff --git a/homeassistant/helpers/restore_state.py b/homeassistant/helpers/restore_state.py index d31c12d0fd52..e34e3c86324f 100644 --- a/homeassistant/helpers/restore_state.py +++ b/homeassistant/helpers/restore_state.py @@ -2,7 +2,6 @@ from __future__ import annotations from abc import ABC, abstractmethod -import asyncio from datetime import datetime, timedelta import logging from typing import Any, cast @@ -18,10 +17,9 @@ from . import start from .entity import Entity from .event import async_track_time_interval from .json import JSONEncoder -from .singleton import singleton from .storage import Store -DATA_RESTORE_STATE_TASK = "restore_state_task" +DATA_RESTORE_STATE = "restore_state" _LOGGER = logging.getLogger(__name__) @@ -96,31 +94,25 @@ class StoredState: ) +async def async_load(hass: HomeAssistant) -> None: + """Load the restore state task.""" + hass.data[DATA_RESTORE_STATE] = await RestoreStateData.async_get_instance(hass) + + +@callback +def async_get(hass: HomeAssistant) -> RestoreStateData: + """Get the restore state data helper.""" + return cast(RestoreStateData, hass.data[DATA_RESTORE_STATE]) + + class RestoreStateData: """Helper class for managing the helper saved data.""" @staticmethod - @singleton(DATA_RESTORE_STATE_TASK) async def async_get_instance(hass: HomeAssistant) -> RestoreStateData: - """Get the singleton instance of this data helper.""" + """Get the instance of this data helper.""" data = RestoreStateData(hass) - - try: - stored_states = await data.store.async_load() - except HomeAssistantError as exc: - _LOGGER.error("Error loading last states", exc_info=exc) - stored_states = None - - if stored_states is None: - _LOGGER.debug("Not creating cache - no saved states found") - data.last_states = {} - else: - data.last_states = { - item["state"]["entity_id"]: StoredState.from_dict(item) - for item in stored_states - if valid_entity_id(item["state"]["entity_id"]) - } - _LOGGER.debug("Created cache with %s", list(data.last_states)) + await data.async_load() async def hass_start(hass: HomeAssistant) -> None: """Start the restore state task.""" @@ -133,8 +125,7 @@ class RestoreStateData: @classmethod async def async_save_persistent_states(cls, hass: HomeAssistant) -> None: """Dump states now.""" - data = await cls.async_get_instance(hass) - await data.async_dump_states() + await async_get(hass).async_dump_states() def __init__(self, hass: HomeAssistant) -> None: """Initialize the restore state data class.""" @@ -145,6 +136,25 @@ class RestoreStateData: self.last_states: dict[str, StoredState] = {} self.entities: dict[str, RestoreEntity] = {} + async def async_load(self) -> None: + """Load the instance of this data helper.""" + try: + stored_states = await self.store.async_load() + except HomeAssistantError as exc: + _LOGGER.error("Error loading last states", exc_info=exc) + stored_states = None + + if stored_states is None: + _LOGGER.debug("Not creating cache - no saved states found") + self.last_states = {} + else: + self.last_states = { + item["state"]["entity_id"]: StoredState.from_dict(item) + for item in stored_states + if valid_entity_id(item["state"]["entity_id"]) + } + _LOGGER.debug("Created cache with %s", list(self.last_states)) + @callback def async_get_stored_states(self) -> list[StoredState]: """Get the set of states which should be stored. @@ -288,21 +298,18 @@ class RestoreEntity(Entity): async def async_internal_added_to_hass(self) -> None: """Register this entity as a restorable entity.""" - _, data = await asyncio.gather( - super().async_internal_added_to_hass(), - RestoreStateData.async_get_instance(self.hass), - ) - data.async_restore_entity_added(self) + await super().async_internal_added_to_hass() + async_get(self.hass).async_restore_entity_added(self) async def async_internal_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass.""" - _, data = await asyncio.gather( - super().async_internal_will_remove_from_hass(), - RestoreStateData.async_get_instance(self.hass), + async_get(self.hass).async_restore_entity_removed( + self.entity_id, self.extra_restore_state_data ) - data.async_restore_entity_removed(self.entity_id, self.extra_restore_state_data) + await super().async_internal_will_remove_from_hass() - async def _async_get_restored_data(self) -> StoredState | None: + @callback + def _async_get_restored_data(self) -> StoredState | None: """Get data stored for an entity, if any.""" if self.hass is None or self.entity_id is None: # Return None if this entity isn't added to hass yet @@ -310,20 +317,17 @@ class RestoreEntity(Entity): "Cannot get last state. Entity not added to hass" ) return None - data = await RestoreStateData.async_get_instance(self.hass) - if self.entity_id not in data.last_states: - return None - return data.last_states[self.entity_id] + return async_get(self.hass).last_states.get(self.entity_id) async def async_get_last_state(self) -> State | None: """Get the entity state from the previous run.""" - if (stored_state := await self._async_get_restored_data()) is None: + if (stored_state := self._async_get_restored_data()) is None: return None return stored_state.state async def async_get_last_extra_data(self) -> ExtraStoredData | None: """Get the entity specific state data from the previous run.""" - if (stored_state := await self._async_get_restored_data()) is None: + if (stored_state := self._async_get_restored_data()) is None: return None return stored_state.extra_data diff --git a/tests/common.py b/tests/common.py index f0d7a8de3c3b..ca164fcaaf87 100644 --- a/tests/common.py +++ b/tests/common.py @@ -61,6 +61,7 @@ from homeassistant.helpers import ( issue_registry as ir, recorder as recorder_helper, restore_state, + restore_state as rs, storage, ) from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -251,12 +252,20 @@ async def async_test_home_assistant(event_loop, load_registries=True): # Load the registries entity.async_setup(hass) if load_registries: - with patch("homeassistant.helpers.storage.Store.async_load", return_value=None): + with patch( + "homeassistant.helpers.storage.Store.async_load", return_value=None + ), patch( + "homeassistant.helpers.restore_state.RestoreStateData.async_setup_dump", + return_value=None, + ), patch( + "homeassistant.helpers.restore_state.start.async_at_start" + ): await asyncio.gather( ar.async_load(hass), dr.async_load(hass), er.async_load(hass), ir.async_load(hass), + rs.async_load(hass), ) hass.data[bootstrap.DATA_REGISTRIES_LOADED] = None @@ -1010,7 +1019,7 @@ def init_recorder_component(hass, add_config=None, db_url="sqlite://"): def mock_restore_cache(hass: HomeAssistant, states: Sequence[State]) -> None: """Mock the DATA_RESTORE_CACHE.""" - key = restore_state.DATA_RESTORE_STATE_TASK + key = restore_state.DATA_RESTORE_STATE data = restore_state.RestoreStateData(hass) now = dt_util.utcnow() @@ -1037,7 +1046,7 @@ def mock_restore_cache_with_extra_data( hass: HomeAssistant, states: Sequence[tuple[State, Mapping[str, Any]]] ) -> None: """Mock the DATA_RESTORE_CACHE.""" - key = restore_state.DATA_RESTORE_STATE_TASK + key = restore_state.DATA_RESTORE_STATE data = restore_state.RestoreStateData(hass) now = dt_util.utcnow() @@ -1060,6 +1069,26 @@ def mock_restore_cache_with_extra_data( hass.data[key] = data +async def async_mock_restore_state_shutdown_restart( + hass: HomeAssistant, +) -> restore_state.RestoreStateData: + """Mock shutting down and saving restore state and restoring.""" + data = restore_state.async_get(hass) + await data.async_dump_states() + await async_mock_load_restore_state_from_storage(hass) + return data + + +async def async_mock_load_restore_state_from_storage( + hass: HomeAssistant, +) -> None: + """Mock loading restore state from storage. + + hass_storage must already be mocked. + """ + await restore_state.async_get(hass).async_load() + + class MockEntity(entity.Entity): """Mock Entity class.""" diff --git a/tests/components/number/test_init.py b/tests/components/number/test_init.py index 67a02968037d..6cd9a53b6f4c 100644 --- a/tests/components/number/test_init.py +++ b/tests/components/number/test_init.py @@ -34,7 +34,10 @@ from homeassistant.helpers.restore_state import STORAGE_KEY as RESTORE_STATE_KEY from homeassistant.setup import async_setup_component from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM -from tests.common import mock_restore_cache_with_extra_data +from tests.common import ( + async_mock_restore_state_shutdown_restart, + mock_restore_cache_with_extra_data, +) class MockDefaultNumberEntity(NumberEntity): @@ -635,7 +638,7 @@ async def test_restore_number_save_state( await hass.async_block_till_done() # Trigger saving state - await hass.async_stop() + 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"] diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index adcbb8084b76..fb079b9ff552 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -35,7 +35,10 @@ from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM -from tests.common import mock_restore_cache_with_extra_data +from tests.common import ( + async_mock_restore_state_shutdown_restart, + mock_restore_cache_with_extra_data, +) @pytest.mark.parametrize( @@ -397,7 +400,7 @@ async def test_restore_sensor_save_state( await hass.async_block_till_done() # Trigger saving state - await hass.async_stop() + 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"] diff --git a/tests/components/text/test_init.py b/tests/components/text/test_init.py index 666ffe187740..d144cc86c915 100644 --- a/tests/components/text/test_init.py +++ b/tests/components/text/test_init.py @@ -20,7 +20,10 @@ from homeassistant.core import HomeAssistant, ServiceCall, State from homeassistant.helpers.restore_state import STORAGE_KEY as RESTORE_STATE_KEY from homeassistant.setup import async_setup_component -from tests.common import mock_restore_cache_with_extra_data +from tests.common import ( + async_mock_restore_state_shutdown_restart, + mock_restore_cache_with_extra_data, +) class MockTextEntity(TextEntity): @@ -141,7 +144,7 @@ async def test_restore_number_save_state( await hass.async_block_till_done() # Trigger saving state - await hass.async_stop() + 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"] diff --git a/tests/components/timer/test_init.py b/tests/components/timer/test_init.py index a60e42eb7681..ae700ce08bf3 100644 --- a/tests/components/timer/test_init.py +++ b/tests/components/timer/test_init.py @@ -47,11 +47,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 -from homeassistant.helpers.restore_state import ( - DATA_RESTORE_STATE_TASK, - RestoreStateData, - StoredState, -) +from homeassistant.helpers.restore_state import StoredState, async_get from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow @@ -838,12 +834,9 @@ async def test_restore_idle(hass: HomeAssistant) -> None: utc_now, ) - data = await RestoreStateData.async_get_instance(hass) - await hass.async_block_till_done() + data = async_get(hass) await data.store.async_save([stored_state.as_dict()]) - - # Emulate a fresh load - hass.data.pop(DATA_RESTORE_STATE_TASK) + await data.async_load() entity = Timer.from_storage( { @@ -878,12 +871,9 @@ async def test_restore_paused(hass: HomeAssistant) -> None: utc_now, ) - data = await RestoreStateData.async_get_instance(hass) - await hass.async_block_till_done() + data = async_get(hass) await data.store.async_save([stored_state.as_dict()]) - - # Emulate a fresh load - hass.data.pop(DATA_RESTORE_STATE_TASK) + await data.async_load() entity = Timer.from_storage( { @@ -922,12 +912,9 @@ async def test_restore_active_resume(hass: HomeAssistant) -> None: utc_now, ) - data = await RestoreStateData.async_get_instance(hass) - await hass.async_block_till_done() + data = async_get(hass) await data.store.async_save([stored_state.as_dict()]) - - # Emulate a fresh load - hass.data.pop(DATA_RESTORE_STATE_TASK) + await data.async_load() entity = Timer.from_storage( { @@ -973,12 +960,9 @@ async def test_restore_active_finished_outside_grace(hass: HomeAssistant) -> Non utc_now, ) - data = await RestoreStateData.async_get_instance(hass) - await hass.async_block_till_done() + data = async_get(hass) await data.store.async_save([stored_state.as_dict()]) - - # Emulate a fresh load - hass.data.pop(DATA_RESTORE_STATE_TASK) + await data.async_load() entity = Timer.from_storage( { diff --git a/tests/components/zha/test_binary_sensor.py b/tests/components/zha/test_binary_sensor.py index 2c0461a3c7c1..2a30e0533764 100644 --- a/tests/components/zha/test_binary_sensor.py +++ b/tests/components/zha/test_binary_sensor.py @@ -21,6 +21,8 @@ from .common import ( ) from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE +from tests.common import async_mock_load_restore_state_from_storage + DEVICE_IAS = { 1: { SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, @@ -186,6 +188,7 @@ async def test_binary_sensor_migration_not_migrated( entity_id = "binary_sensor.fakemanufacturer_fakemodel_iaszone" core_rs(entity_id, state=restored_state, attributes={}) # migration sensor state + await async_mock_load_restore_state_from_storage(hass) zigpy_device = zigpy_device_mock(DEVICE_IAS) zha_device = await zha_device_restored(zigpy_device) @@ -208,6 +211,7 @@ async def test_binary_sensor_migration_already_migrated( entity_id = "binary_sensor.fakemanufacturer_fakemodel_iaszone" core_rs(entity_id, state=STATE_OFF, attributes={"migrated_to_cache": True}) + await async_mock_load_restore_state_from_storage(hass) zigpy_device = zigpy_device_mock(DEVICE_IAS) @@ -243,6 +247,7 @@ async def test_onoff_binary_sensor_restore_state( entity_id = "binary_sensor.fakemanufacturer_fakemodel_opening" core_rs(entity_id, state=restored_state, attributes={}) + await async_mock_load_restore_state_from_storage(hass) zigpy_device = zigpy_device_mock(DEVICE_ONOFF) zha_device = await zha_device_restored(zigpy_device) diff --git a/tests/components/zha/test_select.py b/tests/components/zha/test_select.py index 714e27147bb3..fb1930e3f99b 100644 --- a/tests/components/zha/test_select.py +++ b/tests/components/zha/test_select.py @@ -26,6 +26,8 @@ from homeassistant.util import dt as dt_util from .common import async_enable_traffic, find_entity_id, send_attributes_report from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE +from tests.common import async_mock_load_restore_state_from_storage + @pytest.fixture(autouse=True) def select_select_only(): @@ -176,6 +178,7 @@ async def test_select_restore_state( entity_id = "select.fakemanufacturer_fakemodel_default_siren_tone" core_rs(entity_id, state="Burglar") + await async_mock_load_restore_state_from_storage(hass) zigpy_device = zigpy_device_mock( { diff --git a/tests/components/zha/test_sensor.py b/tests/components/zha/test_sensor.py index 83799147bbe5..7d821ced4a06 100644 --- a/tests/components/zha/test_sensor.py +++ b/tests/components/zha/test_sensor.py @@ -47,6 +47,8 @@ from .common import ( ) from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE +from tests.common import async_mock_load_restore_state_from_storage + ENTITY_ID_PREFIX = "sensor.fakemanufacturer_fakemodel_{}" @@ -530,6 +532,7 @@ def core_rs(hass_storage): ], ) async def test_temp_uom( + hass: HomeAssistant, uom, raw_temp, expected, @@ -544,6 +547,7 @@ async def test_temp_uom( entity_id = "sensor.fake1026_fakemodel1026_004f3202_temperature" if restore: core_rs(entity_id, uom, state=(expected - 2)) + await async_mock_load_restore_state_from_storage(hass) hass = await hass_ms( CONF_UNIT_SYSTEM_METRIC diff --git a/tests/helpers/test_restore_state.py b/tests/helpers/test_restore_state.py index 1e8fa8b7fb41..cf6a078d1370 100644 --- a/tests/helpers/test_restore_state.py +++ b/tests/helpers/test_restore_state.py @@ -8,11 +8,13 @@ from homeassistant.core import CoreState, HomeAssistant, State from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity import Entity from homeassistant.helpers.restore_state import ( - DATA_RESTORE_STATE_TASK, + DATA_RESTORE_STATE, STORAGE_KEY, RestoreEntity, RestoreStateData, StoredState, + async_get, + async_load, ) from homeassistant.util import dt as dt_util @@ -28,12 +30,25 @@ async def test_caching_data(hass: HomeAssistant) -> None: StoredState(State("input_boolean.b2", "on"), None, now), ] - data = await RestoreStateData.async_get_instance(hass) + data = async_get(hass) await hass.async_block_till_done() await data.store.async_save([state.as_dict() for state in stored_states]) # Emulate a fresh load - hass.data.pop(DATA_RESTORE_STATE_TASK) + hass.data.pop(DATA_RESTORE_STATE) + + with patch( + "homeassistant.helpers.restore_state.Store.async_load", + side_effect=HomeAssistantError, + ): + # Failure to load should not be treated as fatal + await async_load(hass) + + data = async_get(hass) + assert data.last_states == {} + + await async_load(hass) + data = async_get(hass) entity = RestoreEntity() entity.hass = hass @@ -55,12 +70,14 @@ async def test_caching_data(hass: HomeAssistant) -> None: async def test_periodic_write(hass: HomeAssistant) -> None: """Test that we write periodiclly but not after stop.""" - data = await RestoreStateData.async_get_instance(hass) + data = async_get(hass) await hass.async_block_till_done() await data.store.async_save([]) # Emulate a fresh load - hass.data.pop(DATA_RESTORE_STATE_TASK) + hass.data.pop(DATA_RESTORE_STATE) + await async_load(hass) + data = async_get(hass) entity = RestoreEntity() entity.hass = hass @@ -101,12 +118,14 @@ async def test_periodic_write(hass: HomeAssistant) -> None: async def test_save_persistent_states(hass: HomeAssistant) -> None: """Test that we cancel the currently running job, save the data, and verify the perdiodic job continues.""" - data = await RestoreStateData.async_get_instance(hass) + data = async_get(hass) await hass.async_block_till_done() await data.store.async_save([]) # Emulate a fresh load - hass.data.pop(DATA_RESTORE_STATE_TASK) + hass.data.pop(DATA_RESTORE_STATE) + await async_load(hass) + data = async_get(hass) entity = RestoreEntity() entity.hass = hass @@ -166,13 +185,15 @@ async def test_hass_starting(hass: HomeAssistant) -> None: StoredState(State("input_boolean.b2", "on"), None, now), ] - data = await RestoreStateData.async_get_instance(hass) + data = async_get(hass) await hass.async_block_till_done() await data.store.async_save([state.as_dict() for state in stored_states]) # Emulate a fresh load hass.state = CoreState.not_running - hass.data.pop(DATA_RESTORE_STATE_TASK) + hass.data.pop(DATA_RESTORE_STATE) + await async_load(hass) + data = async_get(hass) entity = RestoreEntity() entity.hass = hass @@ -223,7 +244,7 @@ async def test_dump_data(hass: HomeAssistant) -> None: entity.entity_id = "input_boolean.b1" await entity.async_internal_added_to_hass() - data = await RestoreStateData.async_get_instance(hass) + data = async_get(hass) now = dt_util.utcnow() data.last_states = { "input_boolean.b0": StoredState(State("input_boolean.b0", "off"), None, now), @@ -297,7 +318,7 @@ async def test_dump_error(hass: HomeAssistant) -> None: entity.entity_id = "input_boolean.b1" await entity.async_internal_added_to_hass() - data = await RestoreStateData.async_get_instance(hass) + data = async_get(hass) with patch( "homeassistant.helpers.restore_state.Store.async_save", @@ -335,7 +356,7 @@ async def test_state_saved_on_remove(hass: HomeAssistant) -> None: "input_boolean.b0", "on", {"complicated": {"value": {1, 2, now}}} ) - data = await RestoreStateData.async_get_instance(hass) + data = async_get(hass) # No last states should currently be saved assert not data.last_states From bfec3d68dd372471aa72ef27984a5fca764ac179 Mon Sep 17 00:00:00 2001 From: mkmer Date: Tue, 30 May 2023 21:48:39 -0400 Subject: [PATCH 723/822] Calculate _attr_native_value when no restore state and appliance is running in Whirlpool (#88559) * Initialize _attr_native_value when running * Fix return type on update_sensor_state() move init at startup if _attr_native_value is None * allow update _attr_native_value when running and none --- homeassistant/components/whirlpool/sensor.py | 9 ++++++--- tests/components/whirlpool/test_sensor.py | 19 ++++++++++++++++++- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/whirlpool/sensor.py b/homeassistant/components/whirlpool/sensor.py index 9c26a0319f7b..5b83c35cca42 100644 --- a/homeassistant/components/whirlpool/sensor.py +++ b/homeassistant/components/whirlpool/sensor.py @@ -290,12 +290,15 @@ class WasherDryerTimeClass(RestoreSensor): if machine_state is MachineState.RunningMainCycle: self._running = True + new_timestamp = now + timedelta( seconds=int(self._wd.get_attribute("Cavity_TimeStatusEstTimeRemaining")) ) - if isinstance(self._attr_native_value, datetime) and abs( - new_timestamp - self._attr_native_value - ) > timedelta(seconds=60): + if ( + isinstance(self._attr_native_value, datetime) + and abs(new_timestamp - self._attr_native_value) > timedelta(seconds=60) + or self._attr_native_value is None + ): self._attr_native_value = new_timestamp self._async_write_ha_state() diff --git a/tests/components/whirlpool/test_sensor.py b/tests/components/whirlpool/test_sensor.py index 429e8895ad85..063ebd49c4cf 100644 --- a/tests/components/whirlpool/test_sensor.py +++ b/tests/components/whirlpool/test_sensor.py @@ -17,7 +17,7 @@ async def update_sensor_state( hass: HomeAssistant, entity_id: str, mock_sensor_api_instance: MagicMock, -) -> None: +) -> State: """Simulate an update trigger from the API.""" for call in mock_sensor_api_instance.register_attr_callback.call_args_list: @@ -300,6 +300,23 @@ async def test_restore_state( assert state.state == thetimestamp.isoformat() +async def test_no_restore_state( + hass: HomeAssistant, + mock_sensor_api_instances: MagicMock, + mock_sensor1_api: MagicMock, +) -> None: + """Test sensor restore state with no restore.""" + # create and add entry + entity_id = "sensor.washer_end_time" + await init_integration(hass) + # restore from cache + state = hass.states.get(entity_id) + state.state = "unknown" + + state = await update_sensor_state(hass, entity_id, mock_sensor1_api) + state.state = datetime.now().isoformat() + + async def test_callback( hass: HomeAssistant, mock_sensor_api_instances: MagicMock, From 4a3f34144452c652ffcb2dab8644bd2cbb3efc2d Mon Sep 17 00:00:00 2001 From: leranp Date: Wed, 31 May 2023 05:27:32 +0300 Subject: [PATCH 724/822] Language codes for Hebrew (#93681) * Language codes for Hebrew There is 2 optional code for Hebrew: he-IL is the new code iw-IL is the old code , the google cloud STT for example is using the old code (iw) * Update language.py * Update test_language.py * Update test_language.py * Update test_language.py * Simplify duplicate language check --------- Co-authored-by: Paulus Schoutsen --- homeassistant/util/language.py | 12 +++++++++--- tests/util/test_language.py | 36 ++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/homeassistant/util/language.py b/homeassistant/util/language.py index 615024e059cc..4ec8c74ffa9f 100644 --- a/homeassistant/util/language.py +++ b/homeassistant/util/language.py @@ -10,6 +10,14 @@ import re from homeassistant.const import MATCH_ALL SEPARATOR_RE = re.compile(r"[-_]") +SAME_LANGUAGES = ( + # no = spoken Norwegian + # nb = written Norwegian (Bokmål) + ("nb", "no"), + # he = Hebrew new code + # iw = Hebrew old code + ("he", "iw"), +) def preferred_regions( @@ -60,9 +68,7 @@ def is_language_match(lang_1: str, lang_2: str) -> bool: # Exact match return True - if {lang_1, lang_2} == {"no", "nb"}: - # no = spoken Norwegian - # nb = written Norwegian (Bokmål) + if tuple(sorted([lang_1, lang_2])) in SAME_LANGUAGES: return True return False diff --git a/tests/util/test_language.py b/tests/util/test_language.py index 41f3ef4b301b..cc4e5d66c316 100644 --- a/tests/util/test_language.py +++ b/tests/util/test_language.py @@ -226,3 +226,39 @@ def test_no_nb_prefer_exact_regions() -> None: "no-AA", ["en-US", "en-GB", "no-AA", "nb-AA"], ) == ["no-AA", "nb-AA"] + + +def test_he_iw_same() -> None: + """Test that the he/iw are interchangeable.""" + assert language.matches( + "he", + ["en-US", "en-GB", "iw"], + ) == ["iw"] + assert language.matches( + "iw", + ["en-US", "en-GB", "he"], + ) == ["he"] + + +def test_he_iw_prefer_exact() -> None: + """Test that the exact language is preferred even if an interchangeable language is available.""" + assert language.matches( + "he", + ["en-US", "en-GB", "iw", "he"], + ) == ["he", "iw"] + assert language.matches( + "he", + ["en-US", "en-GB", "he", "iw"], + ) == ["he", "iw"] + + +def test_he_iw_prefer_exact_regions() -> None: + """Test that the exact language/region is preferred.""" + assert language.matches( + "he-IL", + ["en-US", "en-GB", "iw-IL", "he-IL"], + ) == ["he-IL", "iw-IL"] + assert language.matches( + "he-IL", + ["en-US", "en-GB", "he-IL", "iw-IL"], + ) == ["he-IL", "iw-IL"] From 8cd8355ab16a73ca5ca177998ef2c0c3a1bce3cc Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Tue, 30 May 2023 22:40:55 -0400 Subject: [PATCH 725/822] Bump zwave-js-server-python to 0.49.0 (#93835) * Bump zwave-js-server-python to 0.49.0 * fix tests --- homeassistant/components/zwave_js/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/zwave_js/test_services.py | 8 ++++++++ 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index da144c398ed5..f8be739f826f 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -8,7 +8,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["zwave_js_server"], - "requirements": ["pyserial==3.5", "zwave-js-server-python==0.48.1"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.49.0"], "usb": [ { "vid": "0658", diff --git a/requirements_all.txt b/requirements_all.txt index 539fd0c81c31..ccf2c5eae20f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2761,7 +2761,7 @@ zigpy==0.55.0 zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.48.1 +zwave-js-server-python==0.49.0 # homeassistant.components.zwave_me zwave_me_ws==0.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ff558c109c60..721904d01ba3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2010,7 +2010,7 @@ zigpy-znp==0.11.1 zigpy==0.55.0 # homeassistant.components.zwave_js -zwave-js-server-python==0.48.1 +zwave-js-server-python==0.49.0 # homeassistant.components.zwave_me zwave_me_ws==0.4.3 diff --git a/tests/components/zwave_js/test_services.py b/tests/components/zwave_js/test_services.py index 1c57eca2f26b..d1edfa5040f5 100644 --- a/tests/components/zwave_js/test_services.py +++ b/tests/components/zwave_js/test_services.py @@ -430,6 +430,7 @@ async def test_bulk_set_config_parameters( assert args["nodeId"] == 52 assert args["valueId"] == { "commandClass": 112, + "endpoint": 0, "property": 102, } assert args["value"] == 241 @@ -457,6 +458,7 @@ async def test_bulk_set_config_parameters( assert args["nodeId"] == 52 assert args["valueId"] == { "commandClass": 112, + "endpoint": 0, "property": 102, } assert args["value"] == 241 @@ -486,6 +488,7 @@ async def test_bulk_set_config_parameters( assert args["nodeId"] == 52 assert args["valueId"] == { "commandClass": 112, + "endpoint": 0, "property": 102, } assert args["value"] == 241 @@ -516,6 +519,7 @@ async def test_bulk_set_config_parameters( assert args["nodeId"] == 52 assert args["valueId"] == { "commandClass": 112, + "endpoint": 0, "property": 102, } assert args["value"] == 241 @@ -545,6 +549,7 @@ async def test_bulk_set_config_parameters( assert args["nodeId"] == 52 assert args["valueId"] == { "commandClass": 112, + "endpoint": 0, "property": 102, } assert args["value"] == 241 @@ -577,6 +582,7 @@ async def test_bulk_set_config_parameters( assert args["nodeId"] == 52 assert args["valueId"] == { "commandClass": 112, + "endpoint": 0, "property": 102, } assert args["value"] == 241 @@ -609,6 +615,7 @@ async def test_bulk_set_config_parameters( assert args["nodeId"] == 52 assert args["valueId"] == { "commandClass": 112, + "endpoint": 0, "property": 102, } assert args["value"] == 241 @@ -648,6 +655,7 @@ async def test_bulk_set_config_parameters_gather( assert args["nodeId"] == 52 assert args["valueId"] == { "commandClass": 112, + "endpoint": 0, "property": 102, } assert args["value"] == 241 From 81561d4d3ecb8990188b4450f3ab76ae9db23a00 Mon Sep 17 00:00:00 2001 From: Luke Date: Tue, 30 May 2023 22:58:27 -0400 Subject: [PATCH 726/822] Add non coordinated Entity to Roborock (#93841) switch to non coordinator entity --- .../components/roborock/coordinator.py | 8 +++ homeassistant/components/roborock/device.py | 68 +++++++++++-------- homeassistant/components/roborock/switch.py | 12 ++-- 3 files changed, 52 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index acaa2bfa3f25..d29d7685f1ed 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -10,6 +10,7 @@ from roborock.local_api import RoborockLocalClient from roborock.roborock_typing import DeviceProp from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -40,6 +41,13 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): ) device_info = DeviceData(device, product_info.model, device_networking.ip) self.api = RoborockLocalClient(device_info) + self.device_specification = DeviceInfo( + name=self.device_info.device.name, + identifiers={(DOMAIN, self.device_info.device.duid)}, + manufacturer="Roborock", + model=self.device_info.product.model, + sw_version=self.device_info.device.fv, + ) async def release(self) -> None: """Disconnect from API.""" diff --git a/homeassistant/components/roborock/device.py b/homeassistant/components/roborock/device.py index 39a9524226df..7f54a416d4a1 100644 --- a/homeassistant/components/roborock/device.py +++ b/homeassistant/components/roborock/device.py @@ -4,17 +4,46 @@ from typing import Any from roborock.containers import Status from roborock.exceptions import RoborockException +from roborock.local_api import RoborockLocalClient from roborock.roborock_typing import RoborockCommand from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity import DeviceInfo, Entity from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import RoborockDataUpdateCoordinator -from .const import DOMAIN -class RoborockCoordinatedEntity(CoordinatorEntity[RoborockDataUpdateCoordinator]): +class RoborockEntity(Entity): + """Representation of a base Roborock Entity.""" + + _attr_has_entity_name = True + + def __init__( + self, unique_id: str, device_info: DeviceInfo, api: RoborockLocalClient + ) -> None: + """Initialize the coordinated Roborock Device.""" + self._attr_unique_id = unique_id + self._attr_device_info = device_info + self._api = api + + async def send( + self, command: RoborockCommand, params: dict[str, Any] | list[Any] | None = None + ) -> dict: + """Send a command to a vacuum cleaner.""" + try: + response = await self._api.send_command(command, params) + except RoborockException as err: + raise HomeAssistantError( + f"Error while calling {command.name} with {params}" + ) from err + + return response + + +class RoborockCoordinatedEntity( + RoborockEntity, CoordinatorEntity[RoborockDataUpdateCoordinator] +): """Representation of a base a coordinated Roborock Entity.""" _attr_has_entity_name = True @@ -25,7 +54,13 @@ class RoborockCoordinatedEntity(CoordinatorEntity[RoborockDataUpdateCoordinator] coordinator: RoborockDataUpdateCoordinator, ) -> None: """Initialize the coordinated Roborock Device.""" - super().__init__(coordinator) + RoborockEntity.__init__( + self, + unique_id=unique_id, + device_info=coordinator.device_specification, + api=coordinator.api, + ) + CoordinatorEntity.__init__(self, coordinator=coordinator) self._attr_unique_id = unique_id @property @@ -37,28 +72,3 @@ class RoborockCoordinatedEntity(CoordinatorEntity[RoborockDataUpdateCoordinator] if status: return status return Status({}) - - @property - def device_info(self) -> DeviceInfo: - """Return the device info.""" - return DeviceInfo( - name=self.coordinator.device_info.device.name, - identifiers={(DOMAIN, self.coordinator.device_info.device.duid)}, - manufacturer="Roborock", - model=self.coordinator.device_info.product.model, - sw_version=self.coordinator.device_info.device.fv, - ) - - async def send( - self, command: RoborockCommand, params: dict[str, Any] | list[Any] | None = None - ) -> dict: - """Send a command to a vacuum cleaner.""" - try: - response = await self.coordinator.api.send_command(command, params) - except RoborockException as err: - raise HomeAssistantError( - f"Error while calling {command.name} with {params}" - ) from err - - await self.coordinator.async_request_refresh() - return response diff --git a/homeassistant/components/roborock/switch.py b/homeassistant/components/roborock/switch.py index 7b675c3be384..2bfddefc100b 100644 --- a/homeassistant/components/roborock/switch.py +++ b/homeassistant/components/roborock/switch.py @@ -15,7 +15,7 @@ from homeassistant.util import slugify from .const import DOMAIN from .coordinator import RoborockDataUpdateCoordinator -from .device import RoborockCoordinatedEntity +from .device import RoborockEntity _LOGGER = logging.getLogger(__name__) @@ -25,11 +25,11 @@ class RoborockSwitchDescriptionMixin: """Define an entity description mixin for switch entities.""" # Gets the status of the switch - get_value: Callable[[RoborockCoordinatedEntity], Coroutine[Any, Any, dict]] + get_value: Callable[[RoborockEntity], Coroutine[Any, Any, dict]] # Evaluate the result of get_value to determine a bool evaluate_value: Callable[[dict], bool] # Sets the status of the switch - set_command: Callable[[RoborockCoordinatedEntity, bool], Coroutine[Any, Any, dict]] + set_command: Callable[[RoborockEntity, bool], Coroutine[Any, Any, dict]] @dataclass @@ -78,7 +78,7 @@ async def async_setup_entry( ) -class RoborockSwitchEntity(RoborockCoordinatedEntity, SwitchEntity): +class RoborockSwitchEntity(RoborockEntity, SwitchEntity): """A class to let you turn functionality on Roborock devices on and off.""" entity_description: RoborockSwitchDescription @@ -91,17 +91,15 @@ class RoborockSwitchEntity(RoborockCoordinatedEntity, SwitchEntity): ) -> None: """Create a switch entity.""" self.entity_description = entity_description - super().__init__(unique_id, coordinator) + super().__init__(unique_id, coordinator.device_specification, coordinator.api) async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the switch.""" await self.entity_description.set_command(self, False) - return self.async_schedule_update_ha_state(True) async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the switch.""" await self.entity_description.set_command(self, True) - return self.async_schedule_update_ha_state(True) async def async_update(self) -> None: """Update switch.""" From 23c5e60be030fa28d9fbc46c834e3f66b68101f0 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Wed, 31 May 2023 04:58:41 +0200 Subject: [PATCH 727/822] Swallow error 40000 for songpal power on/off (#80563) * Swallow error 40000 for songpal power on/off * Move ERROR_REQUEST_RETRY to consts * Add tests for the swallow exception behavior * Update tests/components/songpal/test_media_player.py --------- Co-authored-by: Paulus Schoutsen Co-authored-by: Paulus Schoutsen --- homeassistant/components/songpal/const.py | 2 + .../components/songpal/media_player.py | 22 +++++- tests/components/songpal/test_media_player.py | 70 ++++++++++++++----- 3 files changed, 73 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/songpal/const.py b/homeassistant/components/songpal/const.py index f12b77800a9d..496618f35f03 100644 --- a/homeassistant/components/songpal/const.py +++ b/homeassistant/components/songpal/const.py @@ -3,3 +3,5 @@ DOMAIN = "songpal" SET_SOUND_SETTING = "set_sound_setting" CONF_ENDPOINT = "endpoint" + +ERROR_REQUEST_RETRY = 40000 diff --git a/homeassistant/components/songpal/media_player.py b/homeassistant/components/songpal/media_player.py index 6b7cf73b28b8..0d41aec699b7 100644 --- a/homeassistant/components/songpal/media_player.py +++ b/homeassistant/components/songpal/media_player.py @@ -34,7 +34,7 @@ from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import CONF_ENDPOINT, DOMAIN, SET_SOUND_SETTING +from .const import CONF_ENDPOINT, DOMAIN, ERROR_REQUEST_RETRY, SET_SOUND_SETTING _LOGGER = logging.getLogger(__name__) @@ -332,11 +332,27 @@ class SongpalEntity(MediaPlayerEntity): async def async_turn_on(self) -> None: """Turn the device on.""" - return await self._dev.set_power(True) + try: + return await self._dev.set_power(True) + except SongpalException as ex: + if ex.code == ERROR_REQUEST_RETRY: + _LOGGER.debug( + "Swallowing %s, the device might be already in the wanted state", ex + ) + return + raise async def async_turn_off(self) -> None: """Turn the device off.""" - return await self._dev.set_power(False) + try: + return await self._dev.set_power(False) + except SongpalException as ex: + if ex.code == ERROR_REQUEST_RETRY: + _LOGGER.debug( + "Swallowing %s, the device might be already in the wanted state", ex + ) + return + raise async def async_mute_volume(self, mute: bool) -> None: """Mute or unmute the device.""" diff --git a/tests/components/songpal/test_media_player.py b/tests/components/songpal/test_media_player.py index 86a9456fa8b9..d5e89e887d1f 100644 --- a/tests/components/songpal/test_media_player.py +++ b/tests/components/songpal/test_media_player.py @@ -14,7 +14,10 @@ from songpal import ( from homeassistant.components import media_player, songpal from homeassistant.components.media_player import MediaPlayerEntityFeature -from homeassistant.components.songpal.const import SET_SOUND_SETTING +from homeassistant.components.songpal.const import ( + ERROR_REQUEST_RETRY, + SET_SOUND_SETTING, +) from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -53,6 +56,15 @@ def _get_attributes(hass): return state.as_dict()["attributes"] +async def _call(hass, service, **argv): + await hass.services.async_call( + media_player.DOMAIN, + service, + {"entity_id": ENTITY_ID, **argv}, + blocking=True, + ) + + async def test_setup_platform(hass: HomeAssistant) -> None: """Test the legacy setup platform.""" mocked_device = _create_mocked_device(throw_exception=True) @@ -222,32 +234,24 @@ async def test_services(hass: HomeAssistant) -> None: await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - async def _call(service, **argv): - await hass.services.async_call( - media_player.DOMAIN, - service, - {"entity_id": ENTITY_ID, **argv}, - blocking=True, - ) - - await _call(media_player.SERVICE_TURN_ON) - await _call(media_player.SERVICE_TURN_OFF) - await _call(media_player.SERVICE_TOGGLE) + await _call(hass, media_player.SERVICE_TURN_ON) + await _call(hass, media_player.SERVICE_TURN_OFF) + await _call(hass, media_player.SERVICE_TOGGLE) assert mocked_device.set_power.call_count == 3 mocked_device.set_power.assert_has_calls([call(True), call(False), call(False)]) - await _call(media_player.SERVICE_VOLUME_SET, volume_level=0.6) - await _call(media_player.SERVICE_VOLUME_UP) - await _call(media_player.SERVICE_VOLUME_DOWN) + await _call(hass, media_player.SERVICE_VOLUME_SET, volume_level=0.6) + await _call(hass, media_player.SERVICE_VOLUME_UP) + await _call(hass, media_player.SERVICE_VOLUME_DOWN) assert mocked_device.volume1.set_volume.call_count == 3 mocked_device.volume1.set_volume.assert_has_calls([call(60), call(51), call(49)]) - await _call(media_player.SERVICE_VOLUME_MUTE, is_volume_muted=True) + await _call(hass, media_player.SERVICE_VOLUME_MUTE, is_volume_muted=True) mocked_device.volume1.set_mute.assert_called_once_with(True) - await _call(media_player.SERVICE_SELECT_SOURCE, source="none") + await _call(hass, media_player.SERVICE_SELECT_SOURCE, source="none") mocked_device.input1.activate.assert_not_called() - await _call(media_player.SERVICE_SELECT_SOURCE, source="title1") + await _call(hass, media_player.SERVICE_SELECT_SOURCE, source="title1") mocked_device.input1.activate.assert_called_once() await hass.services.async_call( @@ -366,3 +370,33 @@ async def test_disconnected( assert warning_records[0].message.endswith("Got disconnected, trying to reconnect") assert warning_records[1].message.endswith("Connection reestablished") assert not any(x.levelno == logging.ERROR for x in caplog.records) + + +@pytest.mark.parametrize( + "service", [media_player.SERVICE_TURN_ON, media_player.SERVICE_TURN_OFF] +) +@pytest.mark.parametrize( + ("error_code", "swallow"), [(ERROR_REQUEST_RETRY, True), (1234, False)] +) +async def test_error_swallowing(hass, caplog, service, error_code, swallow): + """Test swallowing specific errors on turn_on and turn_off.""" + mocked_device = _create_mocked_device() + entry = MockConfigEntry(domain=songpal.DOMAIN, data=CONF_DATA) + entry.add_to_hass(hass) + + with _patch_media_player_device(mocked_device): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + type(mocked_device).set_power = AsyncMock( + side_effect=[ + SongpalException("Error to swallow", error=(error_code, "Error to swallow")) + ] + ) + + if swallow: + await _call(hass, service) + assert "Swallowing" in caplog.text + else: + with pytest.raises(SongpalException): + await _call(hass, service) From 8244887bb39d1bc94e00658eb492a5a6ca4ebc1e Mon Sep 17 00:00:00 2001 From: Luke Date: Tue, 30 May 2023 23:33:18 -0400 Subject: [PATCH 728/822] Rename confusing device info in Roborock (#93843) rename confusing device info --- .../components/roborock/coordinator.py | 24 +++++++++---------- homeassistant/components/roborock/device.py | 2 +- homeassistant/components/roborock/select.py | 3 ++- homeassistant/components/roborock/sensor.py | 4 +++- homeassistant/components/roborock/switch.py | 2 +- 5 files changed, 19 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index d29d7685f1ed..ba9571a95f5d 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -33,20 +33,20 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): ) -> None: """Initialize.""" super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) - self.device_info = RoborockHassDeviceInfo( + self.roborock_device_info = RoborockHassDeviceInfo( device, device_networking, product_info, DeviceProp(), ) - device_info = DeviceData(device, product_info.model, device_networking.ip) - self.api = RoborockLocalClient(device_info) - self.device_specification = DeviceInfo( - name=self.device_info.device.name, - identifiers={(DOMAIN, self.device_info.device.duid)}, + device_data = DeviceData(device, product_info.model, device_networking.ip) + self.api = RoborockLocalClient(device_data) + self.device_info = DeviceInfo( + name=self.roborock_device_info.device.name, + identifiers={(DOMAIN, self.roborock_device_info.device.duid)}, manufacturer="Roborock", - model=self.device_info.product.model, - sw_version=self.device_info.device.fv, + model=self.roborock_device_info.product.model, + sw_version=self.roborock_device_info.device.fv, ) async def release(self) -> None: @@ -57,10 +57,10 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): """Update device properties.""" device_prop = await self.api.get_prop() if device_prop: - if self.device_info.props: - self.device_info.props.update(device_prop) + if self.roborock_device_info.props: + self.roborock_device_info.props.update(device_prop) else: - self.device_info.props = device_prop + self.roborock_device_info.props = device_prop async def _async_update_data(self) -> DeviceProp: """Update data via library.""" @@ -68,4 +68,4 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): await self._update_device_prop() except RoborockException as ex: raise UpdateFailed(ex) from ex - return self.device_info.props + return self.roborock_device_info.props diff --git a/homeassistant/components/roborock/device.py b/homeassistant/components/roborock/device.py index 7f54a416d4a1..41db2cc08ac7 100644 --- a/homeassistant/components/roborock/device.py +++ b/homeassistant/components/roborock/device.py @@ -57,7 +57,7 @@ class RoborockCoordinatedEntity( RoborockEntity.__init__( self, unique_id=unique_id, - device_info=coordinator.device_specification, + device_info=coordinator.device_info, api=coordinator.api, ) CoordinatorEntity.__init__(self, coordinator=coordinator) diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index d27888a17793..2d76aac33d3b 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -77,7 +77,8 @@ async def async_setup_entry( ) for device_id, coordinator in coordinators.items() for description in SELECT_DESCRIPTIONS - if description.options_lambda(coordinator.device_info.props.status) is not None + if description.options_lambda(coordinator.roborock_device_info.props.status) + is not None ) diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index 5cf2a286213f..ec973addae39 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -140,4 +140,6 @@ class RoborockSensorEntity(RoborockCoordinatedEntity, SensorEntity): @property def native_value(self) -> StateType: """Return the value reported by the sensor.""" - return self.entity_description.value_fn(self.coordinator.device_info.props) + return self.entity_description.value_fn( + self.coordinator.roborock_device_info.props + ) diff --git a/homeassistant/components/roborock/switch.py b/homeassistant/components/roborock/switch.py index 2bfddefc100b..0af07af4de58 100644 --- a/homeassistant/components/roborock/switch.py +++ b/homeassistant/components/roborock/switch.py @@ -91,7 +91,7 @@ class RoborockSwitchEntity(RoborockEntity, SwitchEntity): ) -> None: """Create a switch entity.""" self.entity_description = entity_description - super().__init__(unique_id, coordinator.device_specification, coordinator.api) + super().__init__(unique_id, coordinator.device_info, coordinator.api) async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the switch.""" From 4119d3198ade4c950d9c0f1de0e38a5e0751bd38 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Tue, 30 May 2023 23:52:12 -0400 Subject: [PATCH 729/822] Support zwave config parameters not on endpoint 0 (#93383) * Support zwave config parameters not on endpoint 0 * Update device automation logic * Make endpoint required * Update homeassistant/components/zwave_js/services.py Co-authored-by: Martin Hjelmare * Update homeassistant/components/zwave_js/services.py Co-authored-by: Martin Hjelmare --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/zwave_js/api.py | 6 +- .../components/zwave_js/device_action.py | 3 + .../zwave_js/device_automation_helpers.py | 10 +++- homeassistant/components/zwave_js/services.py | 6 ++ .../components/zwave_js/services.yaml | 28 ++++++++-- tests/components/zwave_js/test_api.py | 55 ++++++++++++++++--- .../components/zwave_js/test_device_action.py | 7 ++- .../zwave_js/test_device_condition.py | 4 +- .../zwave_js/test_device_trigger.py | 4 +- 9 files changed, 102 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 29e0dcf9e069..867405530abb 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -98,6 +98,7 @@ COMMAND_CLASS_ID = "command_class_id" TYPE = "type" PROPERTY = "property" PROPERTY_KEY = "property_key" +ENDPOINT = "endpoint" VALUE = "value" # constants for log config commands @@ -1608,6 +1609,7 @@ async def websocket_refresh_node_cc_values( vol.Required(TYPE): "zwave_js/set_config_parameter", vol.Required(DEVICE_ID): str, vol.Required(PROPERTY): int, + vol.Optional(ENDPOINT, default=0): int, vol.Optional(PROPERTY_KEY): int, vol.Required(VALUE): vol.Any(int, BITMASK_SCHEMA), } @@ -1623,12 +1625,13 @@ async def websocket_set_config_parameter( ) -> None: """Set a config parameter value for a Z-Wave node.""" property_ = msg[PROPERTY] + endpoint = msg[ENDPOINT] property_key = msg.get(PROPERTY_KEY) value = msg[VALUE] try: zwave_value, cmd_status = await async_set_config_parameter( - node, value, property_, property_key=property_key + node, value, property_, property_key=property_key, endpoint=endpoint ) except (InvalidNewValue, NotFoundError, NotImplementedError, SetValueFailed) as err: code = ERR_UNKNOWN_ERROR @@ -1673,6 +1676,7 @@ async def websocket_get_config_parameters( result[value_id] = { "property": zwave_value.property_, "property_key": zwave_value.property_key, + "endpoint": zwave_value.endpoint, "configuration_value_type": zwave_value.configuration_value_type.value, "metadata": { "description": metadata.description, diff --git a/homeassistant/components/zwave_js/device_action.py b/homeassistant/components/zwave_js/device_action.py index bfb7956432be..20c37b5cbb6a 100644 --- a/homeassistant/components/zwave_js/device_action.py +++ b/homeassistant/components/zwave_js/device_action.py @@ -101,6 +101,7 @@ RESET_METER_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( SET_CONFIG_PARAMETER_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( { vol.Required(CONF_TYPE): SERVICE_SET_CONFIG_PARAMETER, + vol.Required(ATTR_ENDPOINT): vol.Coerce(int), vol.Required(ATTR_CONFIG_PARAMETER): vol.Any(int, str), vol.Required(ATTR_CONFIG_PARAMETER_BITMASK): vol.Any(None, int, str), vol.Required(ATTR_VALUE): vol.Coerce(int), @@ -168,6 +169,7 @@ async def async_get_actions( { **base_action, CONF_TYPE: SERVICE_SET_CONFIG_PARAMETER, + ATTR_ENDPOINT: config_value.endpoint, ATTR_CONFIG_PARAMETER: config_value.property_, ATTR_CONFIG_PARAMETER_BITMASK: config_value.property_key, CONF_SUBTYPE: generate_config_parameter_subtype(config_value), @@ -347,6 +349,7 @@ async def async_get_action_capabilities( CommandClass.CONFIGURATION, config[ATTR_CONFIG_PARAMETER], property_key=config[ATTR_CONFIG_PARAMETER_BITMASK], + endpoint=config[ATTR_ENDPOINT], ) value_schema = get_config_parameter_value_schema(node, value_id) if value_schema is None: diff --git a/homeassistant/components/zwave_js/device_automation_helpers.py b/homeassistant/components/zwave_js/device_automation_helpers.py index 11c4fde31375..7a60d491b3cb 100644 --- a/homeassistant/components/zwave_js/device_automation_helpers.py +++ b/homeassistant/components/zwave_js/device_automation_helpers.py @@ -47,9 +47,15 @@ def generate_config_parameter_subtype(config_value: ConfigurationValue) -> str: if config_value.property_key: # Property keys for config values are always an int assert isinstance(config_value.property_key, int) - parameter = f"{parameter}[{hex(config_value.property_key)}]" + parameter = ( + f"{parameter}[{hex(config_value.property_key)}] on endpoint " + f"{config_value.endpoint}" + ) - return f"{parameter} ({config_value.property_name})" + return ( + f"{parameter} ({config_value.property_name}) on endpoint " + f"{config_value.endpoint}" + ) @callback diff --git a/homeassistant/components/zwave_js/services.py b/homeassistant/components/zwave_js/services.py index 47a16ee12737..6f4f9f8e3301 100644 --- a/homeassistant/components/zwave_js/services.py +++ b/homeassistant/components/zwave_js/services.py @@ -213,6 +213,7 @@ class ZWaveServices: cv.ensure_list, [cv.string] ), vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Optional(const.ATTR_ENDPOINT, default=0): vol.Coerce(int), vol.Required(const.ATTR_CONFIG_PARAMETER): vol.Any( vol.Coerce(int), cv.string ), @@ -247,6 +248,7 @@ class ZWaveServices: cv.ensure_list, [cv.string] ), vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Optional(const.ATTR_ENDPOINT, default=0): vol.Coerce(int), vol.Required(const.ATTR_CONFIG_PARAMETER): vol.Coerce(int), vol.Required(const.ATTR_CONFIG_VALUE): vol.Any( vol.Coerce(int), @@ -413,6 +415,7 @@ class ZWaveServices: async def async_set_config_parameter(self, service: ServiceCall) -> None: """Set a config value on a node.""" nodes: set[ZwaveNode] = service.data[const.ATTR_NODES] + endpoint = service.data[const.ATTR_ENDPOINT] property_or_property_name = service.data[const.ATTR_CONFIG_PARAMETER] property_key = service.data.get(const.ATTR_CONFIG_PARAMETER_BITMASK) new_value = service.data[const.ATTR_CONFIG_VALUE] @@ -424,6 +427,7 @@ class ZWaveServices: new_value, property_or_property_name, property_key=property_key, + endpoint=endpoint, ) for node in nodes ), @@ -448,6 +452,7 @@ class ZWaveServices: ) -> None: """Bulk set multiple partial config values on a node.""" nodes: set[ZwaveNode] = service.data[const.ATTR_NODES] + endpoint = service.data[const.ATTR_ENDPOINT] property_ = service.data[const.ATTR_CONFIG_PARAMETER] new_value = service.data[const.ATTR_CONFIG_VALUE] @@ -457,6 +462,7 @@ class ZWaveServices: node, property_, new_value, + endpoint=endpoint, ) for node in nodes ), diff --git a/homeassistant/components/zwave_js/services.yaml b/homeassistant/components/zwave_js/services.yaml index b9209c6904f7..05e2f8bd9fbb 100644 --- a/homeassistant/components/zwave_js/services.yaml +++ b/homeassistant/components/zwave_js/services.yaml @@ -46,6 +46,14 @@ set_config_parameter: entity: integration: zwave_js fields: + endpoint: + name: Endpoint + description: The configuration parameter's endpoint. + example: 1 + default: 0 + required: false + selector: + text: parameter: name: Parameter description: The (name or id of the) configuration parameter you want to configure. @@ -53,6 +61,12 @@ set_config_parameter: required: true selector: text: + bitmask: + name: Bitmask + description: Target a specific bitmask (see the documentation for more information). + advanced: true + selector: + text: value: name: Value description: The new value to set for this configuration parameter. @@ -60,12 +74,6 @@ set_config_parameter: required: true selector: text: - bitmask: - name: Bitmask - description: Target a specific bitmask (see the documentation for more information). - advanced: true - selector: - text: bulk_set_partial_config_parameters: name: Bulk set partial configuration parameters for a Z-Wave device (Advanced). @@ -74,6 +82,14 @@ bulk_set_partial_config_parameters: entity: integration: zwave_js fields: + endpoint: + name: Endpoint + description: The configuration parameter's endpoint. + example: 1 + default: 0 + required: false + selector: + text: parameter: name: Parameter description: The id of the configuration parameter you want to configure. diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index b3dd4e34ee87..c6a0f7a845d5 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -32,6 +32,7 @@ from zwave_js_server.model.controller import ( from zwave_js_server.model.controller.firmware import ControllerFirmwareUpdateData from zwave_js_server.model.node import Node from zwave_js_server.model.node.firmware import NodeFirmwareUpdateData +from zwave_js_server.model.value import ConfigurationValue, get_value_id_str from homeassistant.components.websocket_api import ERR_INVALID_FORMAT, ERR_NOT_FOUND from homeassistant.components.zwave_js.api import ( @@ -43,6 +44,7 @@ from homeassistant.components.zwave_js.api import ( DEVICE_ID, DSK, ENABLED, + ENDPOINT, ENTRY_ID, ERR_NOT_LOADED, FEATURE, @@ -2756,6 +2758,12 @@ async def test_set_config_parameter( entry = integration ws_client = await hass_ws_client(hass) device = get_device(hass, multisensor_6) + new_value_data = multisensor_6.values[ + get_value_id_str(multisensor_6, 112, 102, 0, 1) + ].data.copy() + new_value_data["endpoint"] = 1 + new_value = ConfigurationValue(multisensor_6, new_value_data) + multisensor_6.values[get_value_id_str(multisensor_6, 112, 102, 1, 1)] = new_value client.async_send_command_no_wait.return_value = None @@ -2787,12 +2795,44 @@ async def test_set_config_parameter( client.async_send_command_no_wait.reset_mock() + client.async_send_command_no_wait.return_value = None + + # Test using a different endpoint + await ws_client.send_json( + { + ID: 2, + TYPE: "zwave_js/set_config_parameter", + DEVICE_ID: device.id, + ENDPOINT: 1, + PROPERTY: 102, + PROPERTY_KEY: 1, + VALUE: 1, + } + ) + + msg = await ws_client.receive_json() + assert msg["success"] + + 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.set_value" + assert args["nodeId"] == 52 + assert args["valueId"] == { + "commandClass": 112, + "endpoint": 1, + "property": 102, + "propertyKey": 1, + } + assert args["value"] == 1 + + client.async_send_command_no_wait.reset_mock() + # Test that hex strings are accepted and converted as expected client.async_send_command_no_wait.return_value = None await ws_client.send_json( { - ID: 2, + ID: 3, TYPE: "zwave_js/set_config_parameter", DEVICE_ID: device.id, PROPERTY: 102, @@ -2824,7 +2864,7 @@ async def test_set_config_parameter( set_param_mock.side_effect = InvalidNewValue("test") await ws_client.send_json( { - ID: 3, + ID: 4, TYPE: "zwave_js/set_config_parameter", DEVICE_ID: device.id, PROPERTY: 102, @@ -2843,7 +2883,7 @@ async def test_set_config_parameter( set_param_mock.side_effect = NotFoundError("test") await ws_client.send_json( { - ID: 4, + ID: 5, TYPE: "zwave_js/set_config_parameter", DEVICE_ID: device.id, PROPERTY: 102, @@ -2862,7 +2902,7 @@ async def test_set_config_parameter( set_param_mock.side_effect = SetValueFailed("test") await ws_client.send_json( { - ID: 5, + ID: 6, TYPE: "zwave_js/set_config_parameter", DEVICE_ID: device.id, PROPERTY: 102, @@ -2881,7 +2921,7 @@ async def test_set_config_parameter( # Test getting non-existent node fails await ws_client.send_json( { - ID: 6, + ID: 7, TYPE: "zwave_js/set_config_parameter", DEVICE_ID: "fake_device", PROPERTY: 102, @@ -2900,7 +2940,7 @@ async def test_set_config_parameter( ): await ws_client.send_json( { - ID: 7, + ID: 8, TYPE: "zwave_js/set_config_parameter", DEVICE_ID: device.id, PROPERTY: 102, @@ -2920,7 +2960,7 @@ async def test_set_config_parameter( await ws_client.send_json( { - ID: 8, + ID: 9, TYPE: "zwave_js/set_config_parameter", DEVICE_ID: device.id, PROPERTY: 102, @@ -2959,6 +2999,7 @@ async def test_get_config_parameters( key = "52-112-0-2" assert result[key]["property"] == 2 assert result[key]["property_key"] is None + assert result[key]["endpoint"] == 0 assert result[key]["metadata"]["type"] == "number" assert result[key]["configuration_value_type"] == "enumerated" assert result[key]["metadata"]["states"] diff --git a/tests/components/zwave_js/test_device_action.py b/tests/components/zwave_js/test_device_action.py index 8672e886ab5c..97631c94501b 100644 --- a/tests/components/zwave_js/test_device_action.py +++ b/tests/components/zwave_js/test_device_action.py @@ -79,9 +79,10 @@ async def test_get_actions( "domain": DOMAIN, "type": "set_config_parameter", "device_id": device.id, + "endpoint": 0, "parameter": 3, "bitmask": None, - "subtype": "3 (Beeper)", + "subtype": "3 (Beeper) on endpoint 0", "metadata": {}, }, ] @@ -188,6 +189,7 @@ async def test_actions( "domain": DOMAIN, "type": "set_config_parameter", "device_id": device.id, + "endpoint": 0, "parameter": 1, "bitmask": None, "subtype": "3 (Beeper)", @@ -510,6 +512,7 @@ async def test_get_action_capabilities( "domain": DOMAIN, "device_id": device.id, "type": "set_config_parameter", + "endpoint": 0, "parameter": 1, "bitmask": None, "subtype": "1 (Temperature Reporting Threshold)", @@ -542,6 +545,7 @@ async def test_get_action_capabilities( "domain": DOMAIN, "device_id": device.id, "type": "set_config_parameter", + "endpoint": 0, "parameter": 10, "bitmask": None, "subtype": "10 (Temperature Reporting Filter)", @@ -569,6 +573,7 @@ async def test_get_action_capabilities( "domain": DOMAIN, "device_id": device.id, "type": "set_config_parameter", + "endpoint": 0, "parameter": 2, "bitmask": None, "subtype": "2 (HVAC Settings)", diff --git a/tests/components/zwave_js/test_device_condition.py b/tests/components/zwave_js/test_device_condition.py index b66e804eb80c..11213d9c3754 100644 --- a/tests/components/zwave_js/test_device_condition.py +++ b/tests/components/zwave_js/test_device_condition.py @@ -28,7 +28,7 @@ from tests.common import async_get_device_automations, async_mock_service @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant): """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") @@ -63,7 +63,7 @@ async def test_get_conditions( "type": "config_parameter", "device_id": device.id, "value_id": value_id, - "subtype": f"{config_value.property_} ({name})", + "subtype": f"{config_value.property_} ({name}) on endpoint 0", "metadata": {}, }, { diff --git a/tests/components/zwave_js/test_device_trigger.py b/tests/components/zwave_js/test_device_trigger.py index 7f324098324c..a8f5ff98fdf1 100644 --- a/tests/components/zwave_js/test_device_trigger.py +++ b/tests/components/zwave_js/test_device_trigger.py @@ -32,7 +32,7 @@ from tests.common import ( @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant): """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") @@ -1292,7 +1292,7 @@ async def test_get_value_updated_config_parameter_triggers( "property_key": None, "endpoint": 0, "command_class": CommandClass.CONFIGURATION.value, - "subtype": "3 (Beeper)", + "subtype": "3 (Beeper) on endpoint 0", "metadata": {}, } triggers = await async_get_device_automations( From 661aedde90ac2c2a881601fb7dba18de8cf03666 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 31 May 2023 09:02:48 +0200 Subject: [PATCH 730/822] Remove MQTT platform schema warnings (#93785) --- .../components/mqtt/alarm_control_panel.py | 14 +----- .../components/mqtt/binary_sensor.py | 8 ---- homeassistant/components/mqtt/button.py | 15 +----- homeassistant/components/mqtt/camera.py | 13 +---- homeassistant/components/mqtt/climate.py | 14 +----- homeassistant/components/mqtt/cover.py | 13 +---- .../components/mqtt/device_tracker.py | 6 --- homeassistant/components/mqtt/fan.py | 13 +---- homeassistant/components/mqtt/humidifier.py | 14 +----- .../components/mqtt/light/__init__.py | 8 +--- homeassistant/components/mqtt/lock.py | 13 +---- homeassistant/components/mqtt/mixins.py | 48 +------------------ homeassistant/components/mqtt/number.py | 13 +---- homeassistant/components/mqtt/scene.py | 7 --- homeassistant/components/mqtt/select.py | 12 +---- homeassistant/components/mqtt/sensor.py | 7 --- homeassistant/components/mqtt/siren.py | 13 +---- homeassistant/components/mqtt/strings.json | 10 ---- homeassistant/components/mqtt/switch.py | 14 +----- .../components/mqtt/vacuum/__init__.py | 9 +--- .../components/mqtt/vacuum/schema_legacy.py | 10 +--- .../components/mqtt/vacuum/schema_state.py | 10 +--- tests/components/mqtt/test_init.py | 26 ---------- 23 files changed, 17 insertions(+), 293 deletions(-) diff --git a/homeassistant/components/mqtt/alarm_control_panel.py b/homeassistant/components/mqtt/alarm_control_panel.py index b685daaf6f18..dbed1c8aa9e8 100644 --- a/homeassistant/components/mqtt/alarm_control_panel.py +++ b/homeassistant/components/mqtt/alarm_control_panel.py @@ -41,12 +41,7 @@ from .const import ( CONF_STATE_TOPIC, ) from .debug_info import log_messages -from .mixins import ( - MQTT_ENTITY_COMMON_SCHEMA, - MqttEntity, - async_setup_entry_helper, - warn_for_legacy_schema, -) +from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper from .models import MqttCommandTemplate, MqttValueTemplate, ReceiveMessage from .util import get_mqtt_data, valid_publish_topic, valid_subscribe_topic @@ -112,13 +107,6 @@ PLATFORM_SCHEMA_MODERN = MQTT_BASE_SCHEMA.extend( } ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) -# Configuring MQTT alarm control panels under the alarm_control_panel platform key -# was deprecated in HA Core 2022.6; -# Setup for the legacy YAML format was removed in HA Core 2022.12 -PLATFORM_SCHEMA = vol.All( - warn_for_legacy_schema(alarm.DOMAIN), -) - DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA) diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py index d09e31f65f9a..50af9ef8a555 100644 --- a/homeassistant/components/mqtt/binary_sensor.py +++ b/homeassistant/components/mqtt/binary_sensor.py @@ -43,7 +43,6 @@ from .mixins import ( MqttAvailability, MqttEntity, async_setup_entry_helper, - warn_for_legacy_schema, ) from .models import MqttValueTemplate, ReceiveMessage from .util import get_mqtt_data @@ -69,13 +68,6 @@ PLATFORM_SCHEMA_MODERN = MQTT_RO_SCHEMA.extend( } ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) -# Configuring MQTT Binary sensors under the binary_sensor platform key was deprecated in -# HA Core 2022.6 -# Setup for the legacy YAML format was removed in HA Core 2022.12 -PLATFORM_SCHEMA = vol.All( - warn_for_legacy_schema(binary_sensor.DOMAIN), -) - DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA) diff --git a/homeassistant/components/mqtt/button.py b/homeassistant/components/mqtt/button.py index f81f78a487a0..46ecc16d3855 100644 --- a/homeassistant/components/mqtt/button.py +++ b/homeassistant/components/mqtt/button.py @@ -22,12 +22,7 @@ from .const import ( CONF_QOS, CONF_RETAIN, ) -from .mixins import ( - MQTT_ENTITY_COMMON_SCHEMA, - MqttEntity, - async_setup_entry_helper, - warn_for_legacy_schema, -) +from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper from .models import MqttCommandTemplate from .util import valid_publish_topic @@ -46,14 +41,6 @@ PLATFORM_SCHEMA_MODERN = MQTT_BASE_SCHEMA.extend( } ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) -# Configuring MQTT Buttons under the button platform key was deprecated in -# HA Core 2022.6 -# Setup for the legacy YAML format was removed in HA Core 2022.12 -PLATFORM_SCHEMA = vol.All( - warn_for_legacy_schema(button.DOMAIN), -) - - DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA) diff --git a/homeassistant/components/mqtt/camera.py b/homeassistant/components/mqtt/camera.py index b3a78f4d2ffe..75ab25efcfa2 100644 --- a/homeassistant/components/mqtt/camera.py +++ b/homeassistant/components/mqtt/camera.py @@ -20,12 +20,7 @@ from . import subscription from .config import MQTT_BASE_SCHEMA from .const import CONF_QOS, CONF_TOPIC from .debug_info import log_messages -from .mixins import ( - MQTT_ENTITY_COMMON_SCHEMA, - MqttEntity, - async_setup_entry_helper, - warn_for_legacy_schema, -) +from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper from .models import ReceiveMessage from .util import valid_subscribe_topic @@ -56,12 +51,6 @@ PLATFORM_SCHEMA_MODERN = vol.All( PLATFORM_SCHEMA_BASE.schema, ) -# Configuring MQTT Camera under the camera platform key was deprecated in HA Core 2022.6 -# Setup for the legacy YAML format was removed in HA Core 2022.12 -PLATFORM_SCHEMA = vol.All( - warn_for_legacy_schema(camera.DOMAIN), -) - DISCOVERY_SCHEMA = PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA) diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index 140d9fb128bc..fcb3271f10f9 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -58,12 +58,7 @@ from .const import ( PAYLOAD_NONE, ) from .debug_info import log_messages -from .mixins import ( - MQTT_ENTITY_COMMON_SCHEMA, - MqttEntity, - async_setup_entry_helper, - warn_for_legacy_schema, -) +from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper from .models import ( MqttCommandTemplate, MqttValueTemplate, @@ -389,13 +384,6 @@ PLATFORM_SCHEMA_MODERN = vol.All( valid_humidity_state_configuration, ) -# Configuring MQTT Climate under the climate platform key was deprecated in -# HA Core 2022.6 -# Setup for the legacy YAML format was removed in HA Core 2022.12 -PLATFORM_SCHEMA = vol.All( - warn_for_legacy_schema(climate.DOMAIN), -) - _DISCOVERY_SCHEMA_BASE = _PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA) DISCOVERY_SCHEMA = vol.All( diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index e7c458e6822f..da2f1b4496d4 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -46,12 +46,7 @@ from .const import ( DEFAULT_OPTIMISTIC, ) from .debug_info import log_messages -from .mixins import ( - MQTT_ENTITY_COMMON_SCHEMA, - MqttEntity, - async_setup_entry_helper, - warn_for_legacy_schema, -) +from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper from .models import MqttCommandTemplate, MqttValueTemplate, ReceiveMessage from .util import get_mqtt_data, valid_publish_topic, valid_subscribe_topic @@ -210,12 +205,6 @@ PLATFORM_SCHEMA_MODERN = vol.All( validate_options, ) -# Configuring MQTT Covers under the cover platform key was deprecated in HA Core 2022.6 -# Setup for the legacy YAML format was removed in HA Core 2022.12 -PLATFORM_SCHEMA = vol.All( - warn_for_legacy_schema(cover.DOMAIN), -) - DISCOVERY_SCHEMA = vol.All( cv.removed("tilt_invert_state"), _PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA), diff --git a/homeassistant/components/mqtt/device_tracker.py b/homeassistant/components/mqtt/device_tracker.py index dbe7188da7de..a9c4017593c9 100644 --- a/homeassistant/components/mqtt/device_tracker.py +++ b/homeassistant/components/mqtt/device_tracker.py @@ -36,7 +36,6 @@ from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper, - warn_for_legacy_schema, ) from .models import MqttValueTemplate, ReceiveMessage, ReceivePayloadType from .util import get_mqtt_data, valid_subscribe_topic @@ -78,11 +77,6 @@ DISCOVERY_SCHEMA = vol.All( PLATFORM_SCHEMA_MODERN_BASE.extend({}, extra=vol.REMOVE_EXTRA), valid_config ) -# Configuring MQTT Device Trackers under the device_tracker platform key was deprecated -# in HA Core 2022.6 -# Setup for the legacy YAML format was removed in HA Core 2022.12 -PLATFORM_SCHEMA = vol.All(warn_for_legacy_schema(device_tracker.DOMAIN)) - async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index e8259c608096..f5e92d8ecf95 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -50,12 +50,7 @@ from .const import ( PAYLOAD_NONE, ) from .debug_info import log_messages -from .mixins import ( - MQTT_ENTITY_COMMON_SCHEMA, - MqttEntity, - async_setup_entry_helper, - warn_for_legacy_schema, -) +from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper from .models import ( MessageCallbackType, MqttCommandTemplate, @@ -181,12 +176,6 @@ _PLATFORM_SCHEMA_BASE = MQTT_RW_SCHEMA.extend( } ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) -# Configuring MQTT Fans under the fan platform key was deprecated in HA Core 2022.6 -# Setup for the legacy YAML format was removed in HA Core 2022.12 -PLATFORM_SCHEMA = vol.All( - warn_for_legacy_schema(fan.DOMAIN), -) - PLATFORM_SCHEMA_MODERN = vol.All( _PLATFORM_SCHEMA_BASE, valid_speed_range_configuration, diff --git a/homeassistant/components/mqtt/humidifier.py b/homeassistant/components/mqtt/humidifier.py index 2c6dae54f4c8..f00944fc0915 100644 --- a/homeassistant/components/mqtt/humidifier.py +++ b/homeassistant/components/mqtt/humidifier.py @@ -45,12 +45,7 @@ from .const import ( PAYLOAD_NONE, ) from .debug_info import log_messages -from .mixins import ( - MQTT_ENTITY_COMMON_SCHEMA, - MqttEntity, - async_setup_entry_helper, - warn_for_legacy_schema, -) +from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper from .models import ( MqttCommandTemplate, MqttValueTemplate, @@ -151,13 +146,6 @@ _PLATFORM_SCHEMA_BASE = MQTT_RW_SCHEMA.extend( } ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) -# Configuring MQTT Humidifiers under the humidifier platform key was deprecated in -# HA Core 2022.6 -# Setup for the legacy YAML format was removed in HA Core 2022.12 -PLATFORM_SCHEMA = vol.All( - warn_for_legacy_schema(humidifier.DOMAIN), -) - PLATFORM_SCHEMA_MODERN = vol.All( _PLATFORM_SCHEMA_BASE, valid_humidity_range_configuration, diff --git a/homeassistant/components/mqtt/light/__init__.py b/homeassistant/components/mqtt/light/__init__.py index f91f76c6a82c..5cd42ef19340 100644 --- a/homeassistant/components/mqtt/light/__init__.py +++ b/homeassistant/components/mqtt/light/__init__.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from ..mixins import async_setup_entry_helper, warn_for_legacy_schema +from ..mixins import async_setup_entry_helper from .schema import CONF_SCHEMA, MQTT_LIGHT_SCHEMA_SCHEMA from .schema_basic import ( DISCOVERY_SCHEMA_BASIC, @@ -58,12 +58,6 @@ DISCOVERY_SCHEMA = vol.All( validate_mqtt_light_discovery, ) -# Configuring MQTT Lights under the light platform key was deprecated in HA Core 2022.6 -# Setup for the legacy YAML format was removed in HA Core 2022.12 -PLATFORM_SCHEMA = vol.All( - warn_for_legacy_schema(light.DOMAIN), -) - PLATFORM_SCHEMA_MODERN = vol.All( MQTT_LIGHT_SCHEMA_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA), validate_mqtt_light_modern, diff --git a/homeassistant/components/mqtt/lock.py b/homeassistant/components/mqtt/lock.py index 0598c0354ed9..966cbc211055 100644 --- a/homeassistant/components/mqtt/lock.py +++ b/homeassistant/components/mqtt/lock.py @@ -33,12 +33,7 @@ from .const import ( CONF_STATE_TOPIC, ) from .debug_info import log_messages -from .mixins import ( - MQTT_ENTITY_COMMON_SCHEMA, - MqttEntity, - async_setup_entry_helper, - warn_for_legacy_schema, -) +from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper from .models import ( MqttCommandTemplate, MqttValueTemplate, @@ -94,12 +89,6 @@ PLATFORM_SCHEMA_MODERN = MQTT_RW_SCHEMA.extend( } ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) -# Configuring MQTT Locks under the lock platform key was deprecated in HA Core 2022.6 -# Setup for the legacy YAML format was removed in HA Core 2022.12 -PLATFORM_SCHEMA = vol.All( - warn_for_legacy_schema(lock.DOMAIN), -) - DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA) STATE_CONFIG_KEYS = [ diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index c78fb77bab6a..46744c4d65d1 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -28,7 +28,7 @@ from homeassistant.const import ( CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, ) -from homeassistant.core import Event, HomeAssistant, async_get_hass, callback +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import ( config_validation as cv, device_registry as dr, @@ -50,7 +50,6 @@ from homeassistant.helpers.event import ( async_track_device_registry_updated_event, async_track_entity_registry_updated_event, ) -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.json import json_loads @@ -231,51 +230,6 @@ MQTT_ENTITY_COMMON_SCHEMA = MQTT_AVAILABILITY_SCHEMA.extend( ) -def warn_for_legacy_schema(domain: str) -> Callable[[ConfigType], ConfigType]: - """Warn once when a legacy platform schema is used.""" - warned = set() - - def validator(config: ConfigType) -> ConfigType: - """Return a validator.""" - nonlocal warned - - # Logged error and repair can be removed from HA 2023.6 - if domain in warned: - return config - - _LOGGER.error( - ( - "Manually configured MQTT %s(s) found under platform key '%s', " - "please move to the mqtt integration key, see " - "https://www.home-assistant.io/integrations/%s.mqtt/" - ), - domain, - domain, - domain, - ) - warned.add(domain) - # Register a repair - async_create_issue( - async_get_hass(), - DOMAIN, - f"deprecated_yaml_{domain}", - breaks_in_ha_version="2022.12.0", # Warning first added in 2022.6.0 - is_fixable=False, - severity=IssueSeverity.ERROR, - translation_key="deprecated_yaml", - translation_placeholders={ - "more_info_url": ( - "https://www.home-assistant.io" - f"/integrations/{domain}.mqtt/#new_format" - ), - "platform": domain, - }, - ) - return config - - return validator - - class SetupEntity(Protocol): """Protocol type for async_setup_entities.""" diff --git a/homeassistant/components/mqtt/number.py b/homeassistant/components/mqtt/number.py index 1ab14b2b4f8f..c0cb00211e33 100644 --- a/homeassistant/components/mqtt/number.py +++ b/homeassistant/components/mqtt/number.py @@ -42,12 +42,7 @@ from .const import ( CONF_STATE_TOPIC, ) from .debug_info import log_messages -from .mixins import ( - MQTT_ENTITY_COMMON_SCHEMA, - MqttEntity, - async_setup_entry_helper, - warn_for_legacy_schema, -) +from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper from .models import ( MqttCommandTemplate, MqttValueTemplate, @@ -107,12 +102,6 @@ PLATFORM_SCHEMA_MODERN = vol.All( validate_config, ) -# Configuring MQTT Number under the number platform key was deprecated in HA Core 2022.6 -# Setup for the legacy YAML format was removed in HA Core 2022.12 -PLATFORM_SCHEMA = vol.All( - warn_for_legacy_schema(number.DOMAIN), -) - DISCOVERY_SCHEMA = vol.All( _PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA), validate_config, diff --git a/homeassistant/components/mqtt/scene.py b/homeassistant/components/mqtt/scene.py index dd7f3347845f..f716e4fe46f8 100644 --- a/homeassistant/components/mqtt/scene.py +++ b/homeassistant/components/mqtt/scene.py @@ -24,7 +24,6 @@ from .mixins import ( MQTT_AVAILABILITY_SCHEMA, MqttEntity, async_setup_entry_helper, - warn_for_legacy_schema, ) from .util import valid_publish_topic @@ -46,12 +45,6 @@ PLATFORM_SCHEMA_MODERN = MQTT_BASE_SCHEMA.extend( } ).extend(MQTT_AVAILABILITY_SCHEMA.schema) -# Configuring MQTT Scenes under the scene platform key was deprecated in HA Core 2022.6 -# Setup for the legacy YAML format was removed in HA Core 2022.12 -PLATFORM_SCHEMA = vol.All( - warn_for_legacy_schema(scene.DOMAIN), -) - DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA) diff --git a/homeassistant/components/mqtt/select.py b/homeassistant/components/mqtt/select.py index b783a001f150..26e72af91923 100644 --- a/homeassistant/components/mqtt/select.py +++ b/homeassistant/components/mqtt/select.py @@ -28,12 +28,7 @@ from .const import ( CONF_STATE_TOPIC, ) from .debug_info import log_messages -from .mixins import ( - MQTT_ENTITY_COMMON_SCHEMA, - MqttEntity, - async_setup_entry_helper, - warn_for_legacy_schema, -) +from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper from .models import ( MqttCommandTemplate, MqttValueTemplate, @@ -65,11 +60,6 @@ PLATFORM_SCHEMA_MODERN = MQTT_RW_SCHEMA.extend( }, ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) -# Configuring MQTT Select under the select platform key was deprecated in HA Core 2022.6 -PLATFORM_SCHEMA = vol.All( - warn_for_legacy_schema(select.DOMAIN), -) - DISCOVERY_SCHEMA = vol.All(PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA)) diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index d63c41964bbb..e4b5f61bda0c 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -45,7 +45,6 @@ from .mixins import ( MqttAvailability, MqttEntity, async_setup_entry_helper, - warn_for_legacy_schema, ) from .models import ( MqttValueTemplate, @@ -93,12 +92,6 @@ PLATFORM_SCHEMA_MODERN = vol.All( _PLATFORM_SCHEMA_BASE, ) -# Configuring MQTT Sensors under the sensor platform key was deprecated in -# HA Core 2022.6 -PLATFORM_SCHEMA = vol.All( - warn_for_legacy_schema(sensor.DOMAIN), -) - DISCOVERY_SCHEMA = vol.All( # Deprecated in HA Core 2021.11.0 https://github.com/home-assistant/core/pull/54840 # Removed in HA Core 2023.6.0 diff --git a/homeassistant/components/mqtt/siren.py b/homeassistant/components/mqtt/siren.py index e279deb70b36..4134dd971486 100644 --- a/homeassistant/components/mqtt/siren.py +++ b/homeassistant/components/mqtt/siren.py @@ -50,12 +50,7 @@ from .const import ( PAYLOAD_NONE, ) from .debug_info import log_messages -from .mixins import ( - MQTT_ENTITY_COMMON_SCHEMA, - MqttEntity, - async_setup_entry_helper, - warn_for_legacy_schema, -) +from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper from .models import ( MqttCommandTemplate, MqttValueTemplate, @@ -96,12 +91,6 @@ PLATFORM_SCHEMA_MODERN = MQTT_RW_SCHEMA.extend( }, ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) -# Configuring MQTT Sirens under the siren platform key was deprecated in HA Core 2022.6 -# Setup for the legacy YAML format was removed in HA Core 2022.12 -PLATFORM_SCHEMA = vol.All( - warn_for_legacy_schema(siren.DOMAIN), -) - DISCOVERY_SCHEMA = vol.All(PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA)) MQTT_SIREN_ATTRIBUTES_BLOCKED = frozenset( diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index b55fa5779b8e..b06794c9b329 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -1,14 +1,4 @@ { - "issues": { - "deprecated_yaml": { - "title": "Your manually configured MQTT {platform}(s) needs attention", - "description": "Manually configured MQTT {platform}(s) found under platform key `{platform}`.\n\nPlease move the configuration to the `mqtt` integration key and restart Home Assistant to fix this issue. See the [documentation]({more_info_url}), for more information." - }, - "deprecated_yaml_broker_settings": { - "title": "Deprecated MQTT settings found in `configuration.yaml`", - "description": "The following settings found in `configuration.yaml` were migrated to MQTT config entry and will now override the settings in `configuration.yaml`:\n`{deprecated_settings}`\n\nPlease remove these settings from `configuration.yaml` and restart Home Assistant to fix this issue. See the [documentation]({more_info_url}), for more information." - } - }, "config": { "step": { "broker": { diff --git a/homeassistant/components/mqtt/switch.py b/homeassistant/components/mqtt/switch.py index 521b08d27489..7f4f609f265e 100644 --- a/homeassistant/components/mqtt/switch.py +++ b/homeassistant/components/mqtt/switch.py @@ -37,12 +37,7 @@ from .const import ( PAYLOAD_NONE, ) from .debug_info import log_messages -from .mixins import ( - MQTT_ENTITY_COMMON_SCHEMA, - MqttEntity, - async_setup_entry_helper, - warn_for_legacy_schema, -) +from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper from .models import MqttValueTemplate, ReceiveMessage from .util import get_mqtt_data @@ -64,13 +59,6 @@ PLATFORM_SCHEMA_MODERN = MQTT_RW_SCHEMA.extend( } ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) -# Configuring MQTT Switches under the switch platform key was deprecated in -# HA Core 2022.6 -# Setup for the legacy YAML format was removed in HA Core 2022.12 -PLATFORM_SCHEMA = vol.All( - warn_for_legacy_schema(switch.DOMAIN), -) - DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA) diff --git a/homeassistant/components/mqtt/vacuum/__init__.py b/homeassistant/components/mqtt/vacuum/__init__.py index 86d94883c906..068bc183ec41 100644 --- a/homeassistant/components/mqtt/vacuum/__init__.py +++ b/homeassistant/components/mqtt/vacuum/__init__.py @@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from ..mixins import async_setup_entry_helper, warn_for_legacy_schema +from ..mixins import async_setup_entry_helper from .schema import CONF_SCHEMA, LEGACY, MQTT_VACUUM_SCHEMA, STATE from .schema_legacy import ( DISCOVERY_SCHEMA_LEGACY, @@ -46,13 +46,6 @@ DISCOVERY_SCHEMA = vol.All( MQTT_VACUUM_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA), validate_mqtt_vacuum_discovery ) -# Configuring MQTT Vacuums under the vacuum platform key was deprecated in -# HA Core 2022.6 -# Setup for the legacy YAML format was removed in HA Core 2022.12 -PLATFORM_SCHEMA = vol.All( - warn_for_legacy_schema(vacuum.DOMAIN), -) - PLATFORM_SCHEMA_MODERN = vol.All( MQTT_VACUUM_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA), validate_mqtt_vacuum_modern ) diff --git a/homeassistant/components/mqtt/vacuum/schema_legacy.py b/homeassistant/components/mqtt/vacuum/schema_legacy.py index 8a2912f19108..6cab62cdb5d0 100644 --- a/homeassistant/components/mqtt/vacuum/schema_legacy.py +++ b/homeassistant/components/mqtt/vacuum/schema_legacy.py @@ -8,7 +8,6 @@ import voluptuous as vol from homeassistant.components.vacuum import ( ATTR_STATUS, - DOMAIN as VACUUM_DOMAIN, ENTITY_ID_FORMAT, VacuumEntity, VacuumEntityFeature, @@ -26,7 +25,7 @@ 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, warn_for_legacy_schema +from ..mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity from ..models import ( MqttValueTemplate, PayloadSentinel, @@ -160,13 +159,6 @@ PLATFORM_SCHEMA_LEGACY_MODERN = ( .extend(MQTT_VACUUM_SCHEMA.schema) ) -# Configuring MQTT Vacuums under the vacuum platform key was deprecated in -# HA Core 2022.6; -# Setup for the legacy YAML format was removed in HA Core 2022.12 -PLATFORM_SCHEMA_LEGACY = vol.All( - warn_for_legacy_schema(VACUUM_DOMAIN), -) - DISCOVERY_SCHEMA_LEGACY = PLATFORM_SCHEMA_LEGACY_MODERN.extend( {}, extra=vol.REMOVE_EXTRA ) diff --git a/homeassistant/components/mqtt/vacuum/schema_state.py b/homeassistant/components/mqtt/vacuum/schema_state.py index 3f8c6953abee..385d60a38866 100644 --- a/homeassistant/components/mqtt/vacuum/schema_state.py +++ b/homeassistant/components/mqtt/vacuum/schema_state.py @@ -6,7 +6,6 @@ from typing import Any, cast import voluptuous as vol from homeassistant.components.vacuum import ( - DOMAIN as VACUUM_DOMAIN, ENTITY_ID_FORMAT, STATE_CLEANING, STATE_DOCKED, @@ -39,7 +38,7 @@ from ..const import ( CONF_STATE_TOPIC, ) from ..debug_info import log_messages -from ..mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, warn_for_legacy_schema +from ..mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity from ..models import ReceiveMessage from ..util import get_mqtt_data, valid_publish_topic from .const import MQTT_VACUUM_ATTRIBUTES_BLOCKED @@ -155,13 +154,6 @@ PLATFORM_SCHEMA_STATE_MODERN = ( .extend(MQTT_VACUUM_SCHEMA.schema) ) -# Configuring MQTT Vacuums under the vacuum platform key was deprecated in -# HA Core 2022.6; -# Setup for the legacy YAML format was removed in HA Core 2022.12 -PLATFORM_SCHEMA_STATE = vol.All( - warn_for_legacy_schema(VACUUM_DOMAIN), -) - DISCOVERY_SCHEMA_STATE = PLATFORM_SCHEMA_STATE_MODERN.extend({}, extra=vol.REMOVE_EXTRA) diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index adbef1464a21..1e3dbc6c323e 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -1,7 +1,6 @@ """The tests for the MQTT component.""" import asyncio from collections.abc import Generator -import copy from datetime import datetime, timedelta from functools import partial import json @@ -3470,31 +3469,6 @@ async def test_subscribe_connection_status( assert mqtt_connected_calls_async[1] is False -# Test existence of removed YAML configuration under the platform key -# This warning and test is to be removed from HA core 2023.6 -async def test_one_deprecation_warning_per_platform( - hass: HomeAssistant, - mqtt_mock_entry: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test a deprecation warning is is logged once per platform.""" - platform = "light" - config = {"platform": "mqtt", "command_topic": "test-topic"} - config1 = copy.deepcopy(config) - config1["name"] = "test1" - config2 = copy.deepcopy(config) - config2["name"] = "test2" - await async_setup_component(hass, platform, {platform: [config1, config2]}) - count = 0 - for record in caplog.records: - if record.levelname == "ERROR" and ( - f"Manually configured MQTT {platform}(s) found under platform key '{platform}'" - in record.message - ): - count += 1 - assert count == 1 - - @patch("homeassistant.components.mqtt.PLATFORMS", [Platform.LIGHT]) async def test_unload_config_entry( hass: HomeAssistant, From 3f3b8330340bd4e8edd32c4be8eee7a628a1745e Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 31 May 2023 09:56:33 +0200 Subject: [PATCH 731/822] Add issue when integration key YAML not supported (#93807) * Add issue when integration key YAML not supported * Fix feedback * Fix review * Update homeassistant/components/homeassistant/strings.json Co-authored-by: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> --------- Co-authored-by: Franck Nijhof Co-authored-by: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> --- homeassistant/components/homeassistant/strings.json | 4 ++++ homeassistant/setup.py | 13 ++++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index 06ecd4fe3ef0..55a40e7ba9d1 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -11,6 +11,10 @@ "python_version": { "title": "Support for Python {current_python_version} is being removed", "description": "Support for running Home Assistant in the current used Python version {current_python_version} is deprecated and will be removed in Home Assistant {breaks_in_ha_version}. Please upgrade Python to {required_python_version} to prevent your Home Assistant instance from breaking." + }, + "integration_key_no_support": { + "title": "This integration does not support YAML configuration", + "description": "The {domain} integration does not support configuration via YAML file. You may not notice any obvious issues with the integration, but the configuration settings defined in YAML are not actually applied. \n\nTo resolve this: 1. Please remove this integration from your YAML configuration file.\n\n2. Restart Home Assistant." } }, "system_health": { diff --git a/homeassistant/setup.py b/homeassistant/setup.py index f0a44eaeeced..3c808033abec 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -18,8 +18,9 @@ from .const import ( PLATFORM_FORMAT, Platform, ) -from .core import CALLBACK_TYPE +from .core import CALLBACK_TYPE, DOMAIN as HOMEASSISTANT_DOMAIN from .exceptions import DependencyError, HomeAssistantError +from .helpers.issue_registry import IssueSeverity, async_create_issue from .helpers.typing import ConfigType from .util import dt as dt_util, ensure_unique_string @@ -235,6 +236,16 @@ async def _async_setup_component( ), domain, ) + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"integration_key_no_support_{domain}", + is_fixable=False, + severity=IssueSeverity.ERROR, + issue_domain=domain, + translation_key="integration_key_no_support", + translation_placeholders={"domain": domain}, + ) start = timer() _LOGGER.info("Setting up %s", domain) From 24ed53cebe4541ebecf3c77f067b5dc513ca2a4d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 31 May 2023 10:10:44 +0200 Subject: [PATCH 732/822] Drop incorrect CONFIG_SCHEMA from the homeassistant integration (#93850) --- homeassistant/components/homeassistant/__init__.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/components/homeassistant/__init__.py b/homeassistant/components/homeassistant/__init__.py index 9befec8910f5..987a4317ba84 100644 --- a/homeassistant/components/homeassistant/__init__.py +++ b/homeassistant/components/homeassistant/__init__.py @@ -60,8 +60,6 @@ SCHEMA_RELOAD_CONFIG_ENTRY = vol.All( SHUTDOWN_SERVICES = (SERVICE_HOMEASSISTANT_STOP, SERVICE_HOMEASSISTANT_RESTART) -CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) - async def async_setup(hass: ha.HomeAssistant, config: ConfigType) -> bool: # noqa: C901 """Set up general services related to Home Assistant.""" From ef4dea07cd23320c260614d47189761dc29b8699 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 31 May 2023 10:11:02 +0200 Subject: [PATCH 733/822] Improve no-yaml warning (#93851) --- homeassistant/setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 3c808033abec..d4b9be05ef45 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -228,6 +228,7 @@ async def _async_setup_component( domain in processed_config and not hasattr(component, "async_setup") and not hasattr(component, "setup") + and not hasattr(component, "CONFIG_SCHEMA") ): _LOGGER.error( ( From b5f5e1c8c376728ae59a529c8e99cc835c485f0d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 31 May 2023 10:13:26 +0200 Subject: [PATCH 734/822] Add empty config schema to integrations t-z (#93853) --- homeassistant/components/tag/__init__.py | 2 ++ homeassistant/components/thread/__init__.py | 3 +++ homeassistant/components/trace/__init__.py | 2 ++ homeassistant/components/usb/__init__.py | 4 +++- homeassistant/components/wake_on_lan/__init__.py | 2 ++ homeassistant/components/webhook/__init__.py | 3 +++ homeassistant/components/websocket_api/__init__.py | 3 +++ 7 files changed, 18 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/tag/__init__.py b/homeassistant/components/tag/__init__.py index cd0dd00afe59..e82083f73ec1 100644 --- a/homeassistant/components/tag/__init__.py +++ b/homeassistant/components/tag/__init__.py @@ -38,6 +38,8 @@ UPDATE_FIELDS = { vol.Optional(LAST_SCANNED): cv.datetime, } +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + class TagIDExistsError(HomeAssistantError): """Raised when an item is not found.""" diff --git a/homeassistant/components/thread/__init__.py b/homeassistant/components/thread/__init__.py index 4fc88479818d..dd2527763ad6 100644 --- a/homeassistant/components/thread/__init__.py +++ b/homeassistant/components/thread/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import DOMAIN @@ -22,6 +23,8 @@ __all__ = [ "async_get_preferred_dataset", ] +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Thread integration.""" diff --git a/homeassistant/components/trace/__init__.py b/homeassistant/components/trace/__init__.py index 5d0b188f724a..84619b7a9830 100644 --- a/homeassistant/components/trace/__init__.py +++ b/homeassistant/components/trace/__init__.py @@ -37,6 +37,8 @@ TRACE_CONFIG_SCHEMA = { vol.Optional(CONF_STORED_TRACES, default=DEFAULT_STORED_TRACES): cv.positive_int } +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + TraceData = dict[str, LimitedSizeDict[str, BaseTrace]] diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py index c6a18a27b422..dcb4009145f0 100644 --- a/homeassistant/components/usb/__init__.py +++ b/homeassistant/components/usb/__init__.py @@ -24,7 +24,7 @@ from homeassistant.core import ( callback as hass_callback, ) from homeassistant.data_entry_flow import BaseServiceInfo -from homeassistant.helpers import discovery_flow, system_info +from homeassistant.helpers import config_validation as cv, discovery_flow, system_info from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.typing import ConfigType from homeassistant.loader import USBMatcher, async_get_usb @@ -47,6 +47,8 @@ __all__ = [ "UsbServiceInfo", ] +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + class USBCallbackMatcher(USBMatcher): """Callback matcher for the USB integration.""" diff --git a/homeassistant/components/wake_on_lan/__init__.py b/homeassistant/components/wake_on_lan/__init__.py index aae640381a2d..54809d6cec3c 100644 --- a/homeassistant/components/wake_on_lan/__init__.py +++ b/homeassistant/components/wake_on_lan/__init__.py @@ -24,6 +24,8 @@ WAKE_ON_LAN_SEND_MAGIC_PACKET_SCHEMA = vol.Schema( } ) +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the wake on LAN component.""" diff --git a/homeassistant/components/webhook/__init__.py b/homeassistant/components/webhook/__init__.py index e58890a1d184..e9e5bdf51e8c 100644 --- a/homeassistant/components/webhook/__init__.py +++ b/homeassistant/components/webhook/__init__.py @@ -16,6 +16,7 @@ import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.components.http.view import HomeAssistantView from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.network import get_url from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass @@ -30,6 +31,8 @@ DEFAULT_METHODS = (METH_POST, METH_PUT) SUPPORTED_METHODS = (METH_GET, METH_HEAD, METH_POST, METH_PUT) URL_WEBHOOK_PATH = "/api/webhook/{webhook_id}" +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + @callback @bind_hass diff --git a/homeassistant/components/websocket_api/__init__.py b/homeassistant/components/websocket_api/__init__.py index c98ca54d25a5..9c2645aec57c 100644 --- a/homeassistant/components/websocket_api/__init__.py +++ b/homeassistant/components/websocket_api/__init__.py @@ -6,6 +6,7 @@ from typing import Final, cast import voluptuous as vol from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass @@ -41,6 +42,8 @@ DOMAIN: Final = const.DOMAIN DEPENDENCIES: Final[tuple[str]] = ("http",) +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + @bind_hass @callback From 204215e0f2d7dad2c3a5455c05c37ad01232bd71 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Wed, 31 May 2023 04:15:21 -0400 Subject: [PATCH 735/822] Cleanup unnecessary pylance warnings (#93849) --- tests/components/zwave_js/test_sensor.py | 41 +++++++++++++----------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index 46fe7ee8df32..baf72f5da588 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -263,49 +263,52 @@ async def test_node_status_sensor( hass: HomeAssistant, client, lock_id_lock_as_id150, integration ) -> None: """Test node status sensor is created and gets updated on node state changes.""" - NODE_STATUS_ENTITY = "sensor.z_wave_module_for_id_lock_150_and_101_node_status" + node_status_entity_id = "sensor.z_wave_module_for_id_lock_150_and_101_node_status" node = lock_id_lock_as_id150 ent_reg = er.async_get(hass) - entity_entry = ent_reg.async_get(NODE_STATUS_ENTITY) + entity_entry = ent_reg.async_get(node_status_entity_id) assert not entity_entry.disabled assert entity_entry.entity_category is EntityCategory.DIAGNOSTIC - assert hass.states.get(NODE_STATUS_ENTITY).state == "alive" + assert hass.states.get(node_status_entity_id).state == "alive" # Test transitions work event = Event( "dead", data={"source": "node", "event": "dead", "nodeId": node.node_id} ) node.receive_event(event) - assert hass.states.get(NODE_STATUS_ENTITY).state == "dead" - assert hass.states.get(NODE_STATUS_ENTITY).attributes[ATTR_ICON] == "mdi:robot-dead" + assert hass.states.get(node_status_entity_id).state == "dead" + assert ( + hass.states.get(node_status_entity_id).attributes[ATTR_ICON] == "mdi:robot-dead" + ) event = Event( "wake up", data={"source": "node", "event": "wake up", "nodeId": node.node_id} ) node.receive_event(event) - assert hass.states.get(NODE_STATUS_ENTITY).state == "awake" - assert hass.states.get(NODE_STATUS_ENTITY).attributes[ATTR_ICON] == "mdi:eye" + assert hass.states.get(node_status_entity_id).state == "awake" + assert hass.states.get(node_status_entity_id).attributes[ATTR_ICON] == "mdi:eye" event = Event( "sleep", data={"source": "node", "event": "sleep", "nodeId": node.node_id} ) node.receive_event(event) - assert hass.states.get(NODE_STATUS_ENTITY).state == "asleep" - assert hass.states.get(NODE_STATUS_ENTITY).attributes[ATTR_ICON] == "mdi:sleep" + assert hass.states.get(node_status_entity_id).state == "asleep" + assert hass.states.get(node_status_entity_id).attributes[ATTR_ICON] == "mdi:sleep" event = Event( "alive", data={"source": "node", "event": "alive", "nodeId": node.node_id} ) node.receive_event(event) - assert hass.states.get(NODE_STATUS_ENTITY).state == "alive" + assert hass.states.get(node_status_entity_id).state == "alive" assert ( - hass.states.get(NODE_STATUS_ENTITY).attributes[ATTR_ICON] == "mdi:heart-pulse" + hass.states.get(node_status_entity_id).attributes[ATTR_ICON] + == "mdi:heart-pulse" ) # Disconnect the client and make sure the entity is still available await client.disconnect() - assert hass.states.get(NODE_STATUS_ENTITY).state != STATE_UNAVAILABLE + assert hass.states.get(node_status_entity_id).state != STATE_UNAVAILABLE # Assert a node status sensor entity is not created for the controller driver = client.driver @@ -330,15 +333,15 @@ async def test_node_status_sensor_not_ready( caplog: pytest.LogCaptureFixture, ) -> None: """Test node status sensor is created and available if node is not ready.""" - NODE_STATUS_ENTITY = "sensor.z_wave_module_for_id_lock_150_and_101_node_status" + node_status_entity_id = "sensor.z_wave_module_for_id_lock_150_and_101_node_status" node = lock_id_lock_as_id150_not_ready assert not node.ready ent_reg = er.async_get(hass) - entity_entry = ent_reg.async_get(NODE_STATUS_ENTITY) + entity_entry = ent_reg.async_get(node_status_entity_id) assert not entity_entry.disabled - assert hass.states.get(NODE_STATUS_ENTITY) - assert hass.states.get(NODE_STATUS_ENTITY).state == "alive" + assert hass.states.get(node_status_entity_id) + assert hass.states.get(node_status_entity_id).state == "alive" # Mark node as ready event = Event( @@ -352,14 +355,14 @@ async def test_node_status_sensor_not_ready( ) node.receive_event(event) assert node.ready - assert hass.states.get(NODE_STATUS_ENTITY) - assert hass.states.get(NODE_STATUS_ENTITY).state == "alive" + assert hass.states.get(node_status_entity_id) + assert hass.states.get(node_status_entity_id).state == "alive" await hass.services.async_call( DOMAIN, SERVICE_REFRESH_VALUE, { - ATTR_ENTITY_ID: NODE_STATUS_ENTITY, + ATTR_ENTITY_ID: node_status_entity_id, }, blocking=True, ) From 59c6220b7c56a85b217e65e2147f52f95eab02e3 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 31 May 2023 11:01:55 +0200 Subject: [PATCH 736/822] Subscribe to device registry changes from entities (#93601) * Subscribe to device registry changes from entities * Use async_track_device_registry_updated_event * Fix unsubscribe * Fix logic, add tests --- homeassistant/helpers/entity.py | 56 +++++++++++++++++++++- tests/helpers/test_entity.py | 83 +++++++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index a1435a572775..ef526f956cd2 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -40,7 +40,10 @@ from homeassistant.util import dt as dt_util, ensure_unique_string, slugify from . import device_registry as dr, entity_registry as er from .device_registry import DeviceEntryType -from .event import async_track_entity_registry_updated_event +from .event import ( + async_track_device_registry_updated_event, + async_track_entity_registry_updated_event, +) from .typing import StateType if TYPE_CHECKING: @@ -265,6 +268,8 @@ class Entity(ABC): # Hold list for functions to call on remove. _on_remove: list[CALLBACK_TYPE] | None = None + _unsub_device_updates: CALLBACK_TYPE | None = None + # Context _context: Context | None = None _context_set: datetime | None = None @@ -926,6 +931,7 @@ class Entity(ABC): self.hass, self.entity_id, self._async_registry_updated ) ) + self._async_subscribe_device_updates() async def async_internal_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass. @@ -946,6 +952,9 @@ class Entity(ABC): if data["action"] != "update": return + if "device_id" in data["changes"]: + self._async_subscribe_device_updates() + ent_reg = er.async_get(self.hass) old = self.registry_entry self.registry_entry = ent_reg.async_get(data["entity_id"]) @@ -967,6 +976,51 @@ class Entity(ABC): self.entity_id = self.registry_entry.entity_id await self.platform.async_add_entities([self]) + @callback + def _async_unsubscribe_device_updates(self) -> None: + """Unsubscribe from device registry updates.""" + if not self._unsub_device_updates: + return + self._unsub_device_updates() + self._unsub_device_updates = None + + @callback + def _async_subscribe_device_updates(self) -> None: + """Subscribe to device registry updates.""" + assert self.registry_entry + + self._async_unsubscribe_device_updates() + + if (device_id := self.registry_entry.device_id) is None: + return + + if not self.has_entity_name: + return + + @callback + def async_device_registry_updated(event: Event) -> None: + """Handle device registry update.""" + data = event.data + + if data["action"] != "update": + return + + if "name" not in data["changes"] and "name_by_user" not in data["changes"]: + return + + self.async_write_ha_state() + + self._unsub_device_updates = async_track_device_registry_updated_event( + self.hass, + device_id, + async_device_registry_updated, + ) + if ( + not self._on_remove + or self._async_unsubscribe_device_updates not in self._on_remove + ): + self.async_on_remove(self._async_unsubscribe_device_updates) + def __repr__(self) -> str: """Return the representation.""" return f"" diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 40c402f6f49b..e556d9d5451e 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -988,6 +988,89 @@ async def test_friendly_name( assert state.attributes.get(ATTR_FRIENDLY_NAME) == expected_friendly_name +@pytest.mark.parametrize( + ( + "entity_name", + "expected_friendly_name1", + "expected_friendly_name2", + "expected_friendly_name3", + ), + ( + ( + "Entity Blu", + "Device Bla Entity Blu", + "Device Bla2 Entity Blu", + "New Device Entity Blu", + ), + ( + None, + "Device Bla", + "Device Bla2", + "New Device", + ), + ), +) +async def test_friendly_name_updated( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + entity_name: str | None, + expected_friendly_name1: str, + expected_friendly_name2: str, + expected_friendly_name3: str, +) -> None: + """Test entity_id is influenced by entity name.""" + + async def async_setup_entry(hass, config_entry, async_add_entities): + """Mock setup entry method.""" + async_add_entities( + [ + MockEntity( + unique_id="qwer", + device_info={ + "identifiers": {("hue", "1234")}, + "connections": {(dr.CONNECTION_NETWORK_MAC, "abcd")}, + "name": "Device Bla", + }, + has_entity_name=True, + name=entity_name, + ), + ] + ) + return True + + platform = MockPlatform(async_setup_entry=async_setup_entry) + config_entry = MockConfigEntry(entry_id="super-mock-id") + entity_platform = MockEntityPlatform( + hass, platform_name=config_entry.domain, platform=platform + ) + + assert await entity_platform.async_setup_entry(config_entry) + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids()) == 1 + state = hass.states.async_all()[0] + assert state.attributes.get(ATTR_FRIENDLY_NAME) == expected_friendly_name1 + + device = device_registry.async_get_device(identifiers={("hue", "1234")}) + device_registry.async_update_device(device.id, name_by_user="Device Bla2") + await hass.async_block_till_done() + + state = hass.states.async_all()[0] + assert state.attributes.get(ATTR_FRIENDLY_NAME) == expected_friendly_name2 + + device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("hue", "5678")}, + name="New Device", + ) + entity_registry.async_update_entity(state.entity_id, device_id=device.id) + await hass.async_block_till_done() + + state = hass.states.async_all()[0] + assert state.attributes.get(ATTR_FRIENDLY_NAME) == expected_friendly_name3 + + async def test_translation_key(hass: HomeAssistant) -> None: """Test translation key property.""" mock_entity1 = entity.Entity() From fa4d9b2c08903b3ec85f7a433b4d47b738511b9f Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 31 May 2023 11:12:58 +0200 Subject: [PATCH 737/822] Rework mqtt climate platform to simplify implementation water_heater platform (#93751) * Rework to simplify water_heater platform * Remove unused type hints * Split shared and specific subscriptions * Convert to abstract class --- homeassistant/components/mqtt/climate.py | 416 +++++++++++++---------- 1 file changed, 229 insertions(+), 187 deletions(-) diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index fcb3271f10f9..f580df9eab11 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -1,6 +1,7 @@ """Support for MQTT climate devices.""" from __future__ import annotations +from abc import ABC, abstractmethod from collections.abc import Callable import functools import logging @@ -435,19 +436,213 @@ async def _async_setup_entity( async_add_entities([MqttClimate(hass, config, config_entry, discovery_data)]) -class MqttClimate(MqttEntity, ClimateEntity): +class MqttTemperatureControlEntity(MqttEntity, ABC): + """Helper entity class to control temperature. + + MqttTemperatureControlEntity supports shared methods for + climate and water_heater platforms. + """ + + _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]], + topic: str, + msg_callback: Callable[[ReceiveMessage], None], + ) -> None: + """Add a subscription.""" + qos: int = self._config[CONF_QOS] + if self._topic[topic] is not None: + topics[topic] = { + "topic": self._topic[topic], + "msg_callback": msg_callback, + "qos": qos, + "encoding": self._config[CONF_ENCODING] or None, + } + + def render_template( + self, msg: ReceiveMessage, template_name: str + ) -> ReceivePayloadType: + """Render a template by name.""" + template = self._value_templates[template_name] + return template(msg.payload) + + @callback + def handle_climate_attribute_received( + self, msg: ReceiveMessage, template_name: str, attr: str + ) -> None: + """Handle climate attributes coming via MQTT.""" + payload = self.render_template(msg, template_name) + if not payload: + _LOGGER.debug( + "Invalid empty payload for attribute %s, ignoring update", + attr, + ) + 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) + + def prepare_subscribe_topics( + self, topics: dict[str, dict[str, Any]] + ) -> None: # noqa: C901 + """(Re)Subscribe to topics.""" + + @callback + @log_messages(self.hass, self.entity_id) + def handle_current_temperature_received(msg: ReceiveMessage) -> None: + """Handle current temperature coming via MQTT.""" + self.handle_climate_attribute_received( + msg, CONF_CURRENT_TEMP_TEMPLATE, "_attr_current_temperature" + ) + + self.add_subscription( + topics, CONF_CURRENT_TEMP_TOPIC, handle_current_temperature_received + ) + + @callback + @log_messages(self.hass, self.entity_id) + def handle_target_temperature_received(msg: ReceiveMessage) -> None: + """Handle target temperature coming via MQTT.""" + self.handle_climate_attribute_received( + msg, CONF_TEMP_STATE_TEMPLATE, "_attr_target_temperature" + ) + + self.add_subscription( + topics, CONF_TEMP_STATE_TOPIC, handle_target_temperature_received + ) + + @callback + @log_messages(self.hass, self.entity_id) + def handle_temperature_low_received(msg: ReceiveMessage) -> None: + """Handle target temperature low coming via MQTT.""" + self.handle_climate_attribute_received( + msg, CONF_TEMP_LOW_STATE_TEMPLATE, "_attr_target_temperature_low" + ) + + self.add_subscription( + topics, CONF_TEMP_LOW_STATE_TOPIC, handle_temperature_low_received + ) + + @callback + @log_messages(self.hass, self.entity_id) + def handle_temperature_high_received(msg: ReceiveMessage) -> None: + """Handle target temperature high coming via MQTT.""" + self.handle_climate_attribute_received( + msg, CONF_TEMP_HIGH_STATE_TEMPLATE, "_attr_target_temperature_high" + ) + + self.add_subscription( + topics, CONF_TEMP_HIGH_STATE_TOPIC, handle_temperature_high_received + ) + + self._sub_state = subscription.async_prepare_subscribe_topics( + self.hass, self._sub_state, topics + ) + + async def _subscribe_topics(self) -> None: + """(Re)Subscribe to topics.""" + await subscription.async_subscribe_topics(self.hass, self._sub_state) + + async def _publish(self, topic: str, payload: PublishPayloadType) -> None: + if self._topic[topic] is not None: + await self.async_publish( + self._topic[topic], + payload, + self._config[CONF_QOS], + self._config[CONF_RETAIN], + self._config[CONF_ENCODING], + ) + + async def _set_climate_attribute( + self, + temp: float | None, + cmnd_topic: str, + cmnd_template: str, + state_topic: str, + attr: str, + ) -> bool: + if temp is None: + return False + changed = False + if self._optimistic or self._topic[state_topic] is None: + # optimistic mode + changed = True + setattr(self, attr, temp) + + payload = self._command_templates[cmnd_template](temp) + await self._publish(cmnd_topic, payload) + return changed + + @abstractmethod + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set hvac mode.""" + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperatures.""" + operation_mode: HVACMode | None + if (operation_mode := kwargs.get(ATTR_HVAC_MODE)) is not None: + await self.async_set_hvac_mode(operation_mode) + + changed = await self._set_climate_attribute( + kwargs.get(ATTR_TEMPERATURE), + CONF_TEMP_COMMAND_TOPIC, + CONF_TEMP_COMMAND_TEMPLATE, + CONF_TEMP_STATE_TOPIC, + "_attr_target_temperature", + ) + + changed |= await self._set_climate_attribute( + kwargs.get(ATTR_TARGET_TEMP_LOW), + CONF_TEMP_LOW_COMMAND_TOPIC, + CONF_TEMP_LOW_COMMAND_TEMPLATE, + CONF_TEMP_LOW_STATE_TOPIC, + "_attr_target_temperature_low", + ) + + changed |= await self._set_climate_attribute( + kwargs.get(ATTR_TARGET_TEMP_HIGH), + CONF_TEMP_HIGH_COMMAND_TOPIC, + CONF_TEMP_HIGH_COMMAND_TEMPLATE, + CONF_TEMP_HIGH_STATE_TOPIC, + "_attr_target_temperature_high", + ) + + if not changed: + return + self.async_write_ha_state() + + +class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): # type: ignore[misc] """Representation of an MQTT climate device.""" _entity_id_format = climate.ENTITY_ID_FORMAT _attributes_extra_blocked = MQTT_CLIMATE_ATTRIBUTES_BLOCKED - _command_templates: dict[str, Callable[[PublishPayloadType], PublishPayloadType]] - _value_templates: dict[str, Callable[[ReceivePayloadType], ReceivePayloadType]] - _feature_preset_mode: bool - _optimistic: bool - _optimistic_preset_mode: bool - _topic: dict[str, Any] - def __init__( self, hass: HomeAssistant, @@ -461,9 +656,9 @@ class MqttClimate(MqttEntity, ClimateEntity): self._attr_hvac_mode = None self._attr_is_aux_heat = None self._attr_swing_mode = None - self._attr_target_temperature_low = None - self._attr_target_temperature_high = None - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) + MqttTemperatureControlEntity.__init__( + self, hass, config, config_entry, discovery_data + ) @staticmethod def config_schema() -> vol.Schema: @@ -582,32 +777,12 @@ class MqttClimate(MqttEntity, ClimateEntity): def _prepare_subscribe_topics(self) -> None: # noqa: C901 """(Re)Subscribe to topics.""" topics: dict[str, dict[str, Any]] = {} - qos: int = self._config[CONF_QOS] - - def add_subscription( - topics: dict[str, dict[str, Any]], - topic: str, - msg_callback: Callable[[ReceiveMessage], None], - ) -> None: - if self._topic[topic] is not None: - topics[topic] = { - "topic": self._topic[topic], - "msg_callback": msg_callback, - "qos": qos, - "encoding": self._config[CONF_ENCODING] or None, - } - - def render_template( - msg: ReceiveMessage, template_name: str - ) -> ReceivePayloadType: - template = self._value_templates[template_name] - return template(msg.payload) @callback @log_messages(self.hass, self.entity_id) def handle_action_received(msg: ReceiveMessage) -> None: """Handle receiving action via MQTT.""" - payload = render_template(msg, CONF_ACTION_TEMPLATE) + payload = self.render_template(msg, CONF_ACTION_TEMPLATE) if not payload or payload == PAYLOAD_NONE: _LOGGER.debug( "Invalid %s action: %s, ignoring", @@ -626,87 +801,17 @@ class MqttClimate(MqttEntity, ClimateEntity): return get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - add_subscription(topics, CONF_ACTION_TOPIC, handle_action_received) - - @callback - def handle_climate_attribute_received( - msg: ReceiveMessage, template_name: str, attr: str - ) -> None: - """Handle climate attributes coming via MQTT.""" - payload = render_template(msg, template_name) - if not payload: - _LOGGER.debug( - "Invalid empty payload for attribute %s, ignoring update", - attr, - ) - 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) - - @callback - @log_messages(self.hass, self.entity_id) - def handle_current_temperature_received(msg: ReceiveMessage) -> None: - """Handle current temperature coming via MQTT.""" - handle_climate_attribute_received( - msg, CONF_CURRENT_TEMP_TEMPLATE, "_attr_current_temperature" - ) - - add_subscription( - topics, CONF_CURRENT_TEMP_TOPIC, handle_current_temperature_received - ) - - @callback - @log_messages(self.hass, self.entity_id) - def handle_target_temperature_received(msg: ReceiveMessage) -> None: - """Handle target temperature coming via MQTT.""" - handle_climate_attribute_received( - msg, CONF_TEMP_STATE_TEMPLATE, "_attr_target_temperature" - ) - - add_subscription( - topics, CONF_TEMP_STATE_TOPIC, handle_target_temperature_received - ) - - @callback - @log_messages(self.hass, self.entity_id) - def handle_temperature_low_received(msg: ReceiveMessage) -> None: - """Handle target temperature low coming via MQTT.""" - handle_climate_attribute_received( - msg, CONF_TEMP_LOW_STATE_TEMPLATE, "_attr_target_temperature_low" - ) - - add_subscription( - topics, CONF_TEMP_LOW_STATE_TOPIC, handle_temperature_low_received - ) - - @callback - @log_messages(self.hass, self.entity_id) - def handle_temperature_high_received(msg: ReceiveMessage) -> None: - """Handle target temperature high coming via MQTT.""" - handle_climate_attribute_received( - msg, CONF_TEMP_HIGH_STATE_TEMPLATE, "_attr_target_temperature_high" - ) - - add_subscription( - topics, CONF_TEMP_HIGH_STATE_TOPIC, handle_temperature_high_received - ) + self.add_subscription(topics, CONF_ACTION_TOPIC, handle_action_received) @callback @log_messages(self.hass, self.entity_id) def handle_current_humidity_received(msg: ReceiveMessage) -> None: """Handle current humidity coming via MQTT.""" - handle_climate_attribute_received( + self.handle_climate_attribute_received( msg, CONF_CURRENT_HUMIDITY_TEMPLATE, "_attr_current_humidity" ) - add_subscription( + self.add_subscription( topics, CONF_CURRENT_HUMIDITY_TOPIC, handle_current_humidity_received ) @@ -715,11 +820,11 @@ class MqttClimate(MqttEntity, ClimateEntity): def handle_target_humidity_received(msg: ReceiveMessage) -> None: """Handle target humidity coming via MQTT.""" - handle_climate_attribute_received( + self.handle_climate_attribute_received( msg, CONF_HUMIDITY_STATE_TEMPLATE, "_attr_target_humidity" ) - add_subscription( + self.add_subscription( topics, CONF_HUMIDITY_STATE_TOPIC, handle_target_humidity_received ) @@ -728,7 +833,7 @@ class MqttClimate(MqttEntity, ClimateEntity): msg: ReceiveMessage, template_name: str, attr: str, mode_list: str ) -> None: """Handle receiving listed mode via MQTT.""" - payload = render_template(msg, template_name) + payload = self.render_template(msg, template_name) if payload not in self._config[mode_list]: _LOGGER.error("Invalid %s mode: %s", mode_list, payload) @@ -744,7 +849,9 @@ class MqttClimate(MqttEntity, ClimateEntity): msg, CONF_MODE_STATE_TEMPLATE, "_attr_hvac_mode", CONF_MODE_LIST ) - add_subscription(topics, CONF_MODE_STATE_TOPIC, handle_current_mode_received) + self.add_subscription( + topics, CONF_MODE_STATE_TOPIC, handle_current_mode_received + ) @callback @log_messages(self.hass, self.entity_id) @@ -757,7 +864,9 @@ class MqttClimate(MqttEntity, ClimateEntity): CONF_FAN_MODE_LIST, ) - add_subscription(topics, CONF_FAN_MODE_STATE_TOPIC, handle_fan_mode_received) + self.add_subscription( + topics, CONF_FAN_MODE_STATE_TOPIC, handle_fan_mode_received + ) @callback @log_messages(self.hass, self.entity_id) @@ -770,7 +879,7 @@ class MqttClimate(MqttEntity, ClimateEntity): CONF_SWING_MODE_LIST, ) - add_subscription( + self.add_subscription( topics, CONF_SWING_MODE_STATE_TOPIC, handle_swing_mode_received ) @@ -779,7 +888,7 @@ class MqttClimate(MqttEntity, ClimateEntity): msg: ReceiveMessage, template_name: str, attr: str ) -> None: """Handle receiving on/off mode via MQTT.""" - payload = render_template(msg, template_name) + payload = self.render_template(msg, template_name) payload_on: str = self._config[CONF_PAYLOAD_ON] payload_off: str = self._config[CONF_PAYLOAD_OFF] @@ -805,13 +914,13 @@ class MqttClimate(MqttEntity, ClimateEntity): msg, CONF_AUX_STATE_TEMPLATE, "_attr_is_aux_heat" ) - add_subscription(topics, CONF_AUX_STATE_TOPIC, handle_aux_mode_received) + self.add_subscription(topics, CONF_AUX_STATE_TOPIC, handle_aux_mode_received) @callback @log_messages(self.hass, self.entity_id) def handle_preset_mode_received(msg: ReceiveMessage) -> None: """Handle receiving preset mode via MQTT.""" - preset_mode = render_template(msg, CONF_PRESET_MODE_VALUE_TEMPLATE) + 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) @@ -819,7 +928,10 @@ class MqttClimate(MqttEntity, ClimateEntity): if not preset_mode: _LOGGER.debug("Ignoring empty preset_mode from '%s'", msg.topic) return - if not self.preset_modes or preset_mode not in self.preset_modes: + if ( + not self._attr_preset_modes + or preset_mode not in self._attr_preset_modes + ): _LOGGER.warning( "'%s' received on topic %s. '%s' is not a valid preset mode", msg.payload, @@ -831,81 +943,11 @@ class MqttClimate(MqttEntity, ClimateEntity): get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - add_subscription( + self.add_subscription( topics, CONF_PRESET_MODE_STATE_TOPIC, handle_preset_mode_received ) - self._sub_state = subscription.async_prepare_subscribe_topics( - self.hass, self._sub_state, topics - ) - - async def _subscribe_topics(self) -> None: - """(Re)Subscribe to topics.""" - await subscription.async_subscribe_topics(self.hass, self._sub_state) - - async def _publish(self, topic: str, payload: PublishPayloadType) -> None: - if self._topic[topic] is not None: - await self.async_publish( - self._topic[topic], - payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], - ) - - async def _set_climate_attribute( - self, - temp: float | None, - cmnd_topic: str, - cmnd_template: str, - state_topic: str, - attr: str, - ) -> bool: - if temp is None: - return False - changed = False - if self._optimistic or self._topic[state_topic] is None: - # optimistic mode - changed = True - setattr(self, attr, temp) - - payload = self._command_templates[cmnd_template](temp) - await self._publish(cmnd_topic, payload) - return changed - - async def async_set_temperature(self, **kwargs: Any) -> None: - """Set new target temperatures.""" - operation_mode: HVACMode | None - if (operation_mode := kwargs.get(ATTR_HVAC_MODE)) is not None: - await self.async_set_hvac_mode(operation_mode) - - changed = await self._set_climate_attribute( - kwargs.get(ATTR_TEMPERATURE), - CONF_TEMP_COMMAND_TOPIC, - CONF_TEMP_COMMAND_TEMPLATE, - CONF_TEMP_STATE_TOPIC, - "_attr_target_temperature", - ) - - changed |= await self._set_climate_attribute( - kwargs.get(ATTR_TARGET_TEMP_LOW), - CONF_TEMP_LOW_COMMAND_TOPIC, - CONF_TEMP_LOW_COMMAND_TEMPLATE, - CONF_TEMP_LOW_STATE_TOPIC, - "_attr_target_temperature_low", - ) - - changed |= await self._set_climate_attribute( - kwargs.get(ATTR_TARGET_TEMP_HIGH), - CONF_TEMP_HIGH_COMMAND_TOPIC, - CONF_TEMP_HIGH_COMMAND_TEMPLATE, - CONF_TEMP_HIGH_STATE_TOPIC, - "_attr_target_temperature_high", - ) - - if not changed: - return - self.async_write_ha_state() + self.prepare_subscribe_topics(topics) async def async_set_humidity(self, humidity: int) -> None: """Set new target humidity.""" From f7b15dbf84cbd0bc4031af1ac42e14cfc8a8e4d3 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 31 May 2023 12:00:45 +0200 Subject: [PATCH 738/822] Fix Timer change service (#93469) --- homeassistant/components/timer/__init__.py | 10 +++- tests/components/timer/test_init.py | 70 ++++++++++++++-------- 2 files changed, 54 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/timer/__init__.py b/homeassistant/components/timer/__init__.py index 90ad5e0491b8..3752f9c9cb5b 100644 --- a/homeassistant/components/timer/__init__.py +++ b/homeassistant/components/timer/__init__.py @@ -336,11 +336,17 @@ 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: + raise HomeAssistantError( + f"Not possible to change timer {self.entity_id} beyond configured duration" + ) + if self._remaining and (self._remaining + duration) < timedelta(): + raise HomeAssistantError( + f"Not possible to change timer {self.entity_id} to negative time remaining" + ) self._listener() - self._listener = None self._end += duration - self._duration += duration self._remaining = self._end - dt_util.utcnow().replace(microsecond=0) self.hass.bus.async_fire(EVENT_TIMER_CHANGED, {ATTR_ENTITY_ID: self.entity_id}) self._listener = async_track_point_in_utc_time( diff --git a/tests/components/timer/test_init.py b/tests/components/timer/test_init.py index ae700ce08bf3..76d92db37021 100644 --- a/tests/components/timer/test_init.py +++ b/tests/components/timer/test_init.py @@ -233,7 +233,7 @@ async def test_methods_and_events(hass: HomeAssistant) -> None: "call": SERVICE_CHANGE, "state": STATUS_ACTIVE, "event": EVENT_TIMER_CHANGED, - "data": {CONF_DURATION: 15}, + "data": {CONF_DURATION: -5}, }, { "call": SERVICE_START, @@ -316,31 +316,51 @@ async def test_start_service(hass: HomeAssistant) -> None: assert state.attributes[ATTR_DURATION] == "0:00:15" assert state.attributes[ATTR_REMAINING] == "0:00:15" - await hass.services.async_call( - DOMAIN, - SERVICE_CHANGE, - {CONF_ENTITY_ID: "timer.test1", CONF_DURATION: 15}, - blocking=True, - ) - await hass.async_block_till_done() - state = hass.states.get("timer.test1") - assert state - assert state.state == STATUS_ACTIVE - assert state.attributes[ATTR_DURATION] == "0:00:30" - assert state.attributes[ATTR_REMAINING] == "0:00:30" + with pytest.raises( + HomeAssistantError, + match="Not possible to change timer timer.test1 beyond configured duration", + ): + await hass.services.async_call( + DOMAIN, + SERVICE_CHANGE, + {CONF_ENTITY_ID: "timer.test1", CONF_DURATION: 20}, + blocking=True, + ) + + with pytest.raises( + HomeAssistantError, + match="Not possible to change timer timer.test1 to negative time remaining", + ): + await hass.services.async_call( + DOMAIN, + SERVICE_CHANGE, + {CONF_ENTITY_ID: "timer.test1", CONF_DURATION: -20}, + blocking=True, + ) await hass.services.async_call( DOMAIN, SERVICE_CHANGE, - {CONF_ENTITY_ID: "timer.test1", CONF_DURATION: -10}, + {CONF_ENTITY_ID: "timer.test1", CONF_DURATION: -3}, blocking=True, ) - await hass.async_block_till_done() state = hass.states.get("timer.test1") assert state assert state.state == STATUS_ACTIVE - assert state.attributes[ATTR_DURATION] == "0:00:20" - assert state.attributes[ATTR_REMAINING] == "0:00:20" + assert state.attributes[ATTR_DURATION] == "0:00:15" + assert state.attributes[ATTR_REMAINING] == "0:00:12" + + await hass.services.async_call( + DOMAIN, + SERVICE_CHANGE, + {CONF_ENTITY_ID: "timer.test1", CONF_DURATION: 2}, + blocking=True, + ) + state = hass.states.get("timer.test1") + assert state + assert state.state == STATUS_ACTIVE + assert state.attributes[ATTR_DURATION] == "0:00:15" + assert state.attributes[ATTR_REMAINING] == "0:00:14" await hass.services.async_call( DOMAIN, SERVICE_CANCEL, {CONF_ENTITY_ID: "timer.test1"}, blocking=True @@ -349,22 +369,24 @@ 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:20" + assert state.attributes[ATTR_DURATION] == "0:00:15" assert ATTR_REMAINING not in state.attributes - with pytest.raises(HomeAssistantError): + with pytest.raises( + HomeAssistantError, + match="Timer timer.test1 is not running, only active timers can be changed", + ): await hass.services.async_call( DOMAIN, SERVICE_CHANGE, - {CONF_ENTITY_ID: "timer.test1", CONF_DURATION: 10}, + {CONF_ENTITY_ID: "timer.test1", CONF_DURATION: 2}, blocking=True, ) - await hass.async_block_till_done() state = hass.states.get("timer.test1") assert state assert state.state == STATUS_IDLE - assert state.attributes[ATTR_DURATION] == "0:00:20" + assert state.attributes[ATTR_DURATION] == "0:00:15" assert ATTR_REMAINING not in state.attributes @@ -372,7 +394,7 @@ async def test_wait_till_timer_expires(hass: HomeAssistant) -> None: """Test for a timer to end.""" hass.state = CoreState.starting - await async_setup_component(hass, DOMAIN, {DOMAIN: {"test1": {CONF_DURATION: 10}}}) + await async_setup_component(hass, DOMAIN, {DOMAIN: {"test1": {CONF_DURATION: 20}}}) state = hass.states.get("timer.test1") assert state @@ -405,7 +427,7 @@ async def test_wait_till_timer_expires(hass: HomeAssistant) -> None: await hass.services.async_call( DOMAIN, SERVICE_CHANGE, - {CONF_ENTITY_ID: "timer.test1", CONF_DURATION: 10}, + {CONF_ENTITY_ID: "timer.test1", CONF_DURATION: -5}, blocking=True, ) await hass.async_block_till_done() From c72477811e2dc12d791b5381c55f8308e777fc23 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 31 May 2023 12:33:56 +0200 Subject: [PATCH 739/822] Fix event typing (#93859) --- 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 4a169687d80a..c7e34ac2eda5 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -316,7 +316,7 @@ def _remove_empty_listener() -> None: def _remove_listener( hass: HomeAssistant, listeners_key: str, - keys: list[str], + keys: Iterable[str], job: HassJob[[Event], Any], callbacks: dict[str, list[HassJob[[Event], Any]]], ) -> None: From 1eb1ea08b07889327edd94ea0bff290eab934167 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Wed, 31 May 2023 07:28:07 -0400 Subject: [PATCH 740/822] Add support for Energy Production CC sensors (#93839) --- homeassistant/components/zwave_js/const.py | 5 + .../components/zwave_js/discovery.py | 5 +- .../zwave_js/discovery_data_template.py | 60 ++++- homeassistant/components/zwave_js/sensor.py | 53 +++- tests/components/zwave_js/conftest.py | 14 + .../fixtures/energy_production_state.json | 241 ++++++++++++++++++ tests/components/zwave_js/test_sensor.py | 52 ++++ 7 files changed, 424 insertions(+), 6 deletions(-) create mode 100644 tests/components/zwave_js/fixtures/energy_production_state.json diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index 6a85780ce78c..672f8f10e2f4 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -133,6 +133,11 @@ ENTITY_DESC_KEY_TARGET_TEMPERATURE = "target_temperature" ENTITY_DESC_KEY_MEASUREMENT = "measurement" ENTITY_DESC_KEY_TOTAL_INCREASING = "total_increasing" +ENTITY_DESC_KEY_ENERGY_PRODUCTION_POWER = "energy_production_power" +ENTITY_DESC_KEY_ENERGY_PRODUCTION_TIME = "energy_production_time" +ENTITY_DESC_KEY_ENERGY_PRODUCTION_TOTAL = "energy_production_total" +ENTITY_DESC_KEY_ENERGY_PRODUCTION_TODAY = "energy_production_today" + # This API key is only for use with Home Assistant. Reach out to Z-Wave JS to apply for # your own (https://github.com/zwave-js/firmware-updates/). API_KEY_FIRMWARE_UPDATE_SERVICE = ( diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index c17cbace6573..62fc665c72ea 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -722,9 +722,10 @@ DISCOVERY_SCHEMAS = [ hint="numeric_sensor", primary_value=ZWaveValueDiscoverySchema( command_class={ - CommandClass.SENSOR_MULTILEVEL, - CommandClass.SENSOR_ALARM, CommandClass.BATTERY, + CommandClass.ENERGY_PRODUCTION, + CommandClass.SENSOR_ALARM, + CommandClass.SENSOR_MULTILEVEL, }, type={ValueType.NUMBER}, ), diff --git a/homeassistant/components/zwave_js/discovery_data_template.py b/homeassistant/components/zwave_js/discovery_data_template.py index 17a6e7c7522e..e5a19f9bf2ef 100644 --- a/homeassistant/components/zwave_js/discovery_data_template.py +++ b/homeassistant/components/zwave_js/discovery_data_template.py @@ -7,6 +7,14 @@ import logging from typing import Any, cast from zwave_js_server.const import CommandClass +from zwave_js_server.const.command_class.energy_production import ( + EnergyProductionParameter, + EnergyProductionScaleType, + PowerScale, + TodaysProductionScale, + TotalProductionScale, + TotalTimeScale, +) from zwave_js_server.const.command_class.meter import ( CURRENT_METER_TYPES, ENERGY_TOTAL_INCREASING_METER_TYPES, @@ -85,6 +93,10 @@ from zwave_js_server.model.value import ( Value as ZwaveValue, get_value_id_str, ) +from zwave_js_server.util.command_class.energy_production import ( + get_energy_production_parameter, + get_energy_production_scale_type, +) from zwave_js_server.util.command_class.meter import get_meter_scale_type from zwave_js_server.util.command_class.multilevel_sensor import ( get_multilevel_sensor_scale_type, @@ -123,6 +135,10 @@ from .const import ( ENTITY_DESC_KEY_CO2, ENTITY_DESC_KEY_CURRENT, ENTITY_DESC_KEY_ENERGY_MEASUREMENT, + ENTITY_DESC_KEY_ENERGY_PRODUCTION_POWER, + ENTITY_DESC_KEY_ENERGY_PRODUCTION_TIME, + ENTITY_DESC_KEY_ENERGY_PRODUCTION_TODAY, + ENTITY_DESC_KEY_ENERGY_PRODUCTION_TOTAL, ENTITY_DESC_KEY_ENERGY_TOTAL_INCREASING, ENTITY_DESC_KEY_HUMIDITY, ENTITY_DESC_KEY_ILLUMINANCE, @@ -138,6 +154,18 @@ from .const import ( ) from .helpers import ZwaveValueID +ENERGY_PRODUCTION_DEVICE_CLASS_MAP: dict[str, list[EnergyProductionParameter]] = { + ENTITY_DESC_KEY_ENERGY_PRODUCTION_TIME: [EnergyProductionParameter.TOTAL_TIME], + ENTITY_DESC_KEY_ENERGY_PRODUCTION_TODAY: [ + EnergyProductionParameter.TODAYS_PRODUCTION + ], + ENTITY_DESC_KEY_ENERGY_PRODUCTION_TOTAL: [ + EnergyProductionParameter.TOTAL_PRODUCTION + ], + ENTITY_DESC_KEY_ENERGY_PRODUCTION_POWER: [EnergyProductionParameter.POWER], +} + + METER_DEVICE_CLASS_MAP: dict[str, list[MeterScaleType]] = { ENTITY_DESC_KEY_CURRENT: CURRENT_METER_TYPES, ENTITY_DESC_KEY_VOLTAGE: VOLTAGE_METER_TYPES, @@ -160,6 +188,16 @@ MULTILEVEL_SENSOR_DEVICE_CLASS_MAP: dict[str, list[MultilevelSensorType]] = { ENTITY_DESC_KEY_VOLTAGE: VOLTAGE_SENSORS, } +ENERGY_PRODUCTION_UNIT_MAP: dict[str, list[EnergyProductionScaleType]] = { + UnitOfEnergy.WATT_HOUR: [ + TotalProductionScale.WATT_HOURS, + TodaysProductionScale.WATT_HOURS, + ], + UnitOfPower.WATT: [PowerScale.WATTS], + UnitOfTime.SECONDS: [TotalTimeScale.SECONDS], + UnitOfTime.HOURS: [TotalTimeScale.HOURS], +} + METER_UNIT_MAP: dict[str, list[MeterScaleType]] = { UnitOfElectricCurrent.AMPERE: METER_UNIT_AMPERE, UnitOfVolume.CUBIC_FEET: UNIT_CUBIC_FEET, @@ -320,12 +358,18 @@ class NumericSensorDataTemplate(BaseDiscoverySchemaDataTemplate): @staticmethod def find_key_from_matching_set( - enum_value: MultilevelSensorType | MultilevelSensorScaleType | MeterScaleType, + enum_value: MultilevelSensorType + | MultilevelSensorScaleType + | MeterScaleType + | EnergyProductionParameter + | EnergyProductionScaleType, set_map: Mapping[ str, list[MultilevelSensorType] | list[MultilevelSensorScaleType] - | list[MeterScaleType], + | list[MeterScaleType] + | list[EnergyProductionScaleType] + | list[EnergyProductionParameter], ], ) -> str | None: """Find a key in a set map that matches a given enum value.""" @@ -387,6 +431,18 @@ class NumericSensorDataTemplate(BaseDiscoverySchemaDataTemplate): if key: return NumericSensorDataTemplateData(key, unit) + if value.command_class == CommandClass.ENERGY_PRODUCTION: + energy_production_parameter = get_energy_production_parameter(value) + energy_production_scale_type = get_energy_production_scale_type(value) + unit = self.find_key_from_matching_set( + energy_production_scale_type, ENERGY_PRODUCTION_UNIT_MAP + ) + key = self.find_key_from_matching_set( + energy_production_parameter, ENERGY_PRODUCTION_DEVICE_CLASS_MAP + ) + if key: + return NumericSensorDataTemplateData(key, unit) + return NumericSensorDataTemplateData() diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 074bfa212dbe..234062111f0f 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -58,6 +58,10 @@ from .const import ( ENTITY_DESC_KEY_CO2, ENTITY_DESC_KEY_CURRENT, ENTITY_DESC_KEY_ENERGY_MEASUREMENT, + ENTITY_DESC_KEY_ENERGY_PRODUCTION_POWER, + ENTITY_DESC_KEY_ENERGY_PRODUCTION_TIME, + ENTITY_DESC_KEY_ENERGY_PRODUCTION_TODAY, + ENTITY_DESC_KEY_ENERGY_PRODUCTION_TOTAL, ENTITY_DESC_KEY_ENERGY_TOTAL_INCREASING, ENTITY_DESC_KEY_HUMIDITY, ENTITY_DESC_KEY_ILLUMINANCE, @@ -235,6 +239,50 @@ ENTITY_DESCRIPTION_KEY_DEVICE_CLASS_MAP: dict[ device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, ), + ( + ENTITY_DESC_KEY_ENERGY_PRODUCTION_TIME, + UnitOfTime.SECONDS, + ): SensorEntityDescription( + ENTITY_DESC_KEY_ENERGY_PRODUCTION_TIME, + name="Energy production time", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + ), + (ENTITY_DESC_KEY_ENERGY_PRODUCTION_TIME, UnitOfTime.HOURS): SensorEntityDescription( + ENTITY_DESC_KEY_ENERGY_PRODUCTION_TIME, + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.HOURS, + ), + ( + ENTITY_DESC_KEY_ENERGY_PRODUCTION_TODAY, + UnitOfEnergy.WATT_HOUR, + ): SensorEntityDescription( + ENTITY_DESC_KEY_ENERGY_PRODUCTION_TODAY, + name="Energy production today", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + ), + ( + ENTITY_DESC_KEY_ENERGY_PRODUCTION_TOTAL, + UnitOfEnergy.WATT_HOUR, + ): SensorEntityDescription( + ENTITY_DESC_KEY_ENERGY_PRODUCTION_TOTAL, + name="Energy production total", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + ), + ( + ENTITY_DESC_KEY_ENERGY_PRODUCTION_POWER, + UnitOfPower.WATT, + ): SensorEntityDescription( + ENTITY_DESC_KEY_POWER, + name="Energy production power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + ), } # These descriptions are without device class. @@ -547,13 +595,14 @@ class ZwaveSensor(ZWaveBaseEntity, SensorEntity): unit_of_measurement: str | None = None, ) -> None: """Initialize a ZWaveSensorBase entity.""" - super().__init__(config_entry, driver, info) self.entity_description = entity_description + super().__init__(config_entry, driver, info) self._attr_native_unit_of_measurement = unit_of_measurement # Entity class attributes self._attr_force_update = True - self._attr_name = self.generate_name(include_value_name=True) + if not entity_description.name: + self._attr_name = self.generate_name(include_value_name=True) @property def native_value(self) -> StateType: diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index af23fbef32ee..860e5742c808 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -624,6 +624,12 @@ def indicator_test_state_fixture(): return json.loads(load_fixture("zwave_js/indicator_test_state.json")) +@pytest.fixture(name="energy_production_state", scope="session") +def energy_production_state_fixture(): + """Load a mock node with energy production CC state fixture data.""" + return json.loads(load_fixture("zwave_js/energy_production_state.json")) + + # model fixtures @@ -1191,3 +1197,11 @@ def indicator_test_fixture(client, indicator_test_state): node = Node(client, copy.deepcopy(indicator_test_state)) client.driver.controller.nodes[node.node_id] = node return node + + +@pytest.fixture(name="energy_production") +def energy_prodution_fixture(client, energy_production_state): + """Mock a mock node with Energy Production CC.""" + node = Node(client, copy.deepcopy(energy_production_state)) + client.driver.controller.nodes[node.node_id] = node + return node diff --git a/tests/components/zwave_js/fixtures/energy_production_state.json b/tests/components/zwave_js/fixtures/energy_production_state.json new file mode 100644 index 000000000000..b074e51c499a --- /dev/null +++ b/tests/components/zwave_js/fixtures/energy_production_state.json @@ -0,0 +1,241 @@ +{ + "nodeId": 2, + "index": 0, + "status": 4, + "ready": true, + "isListening": true, + "isRouting": true, + "isSecure": false, + "interviewAttempts": 1, + "endpoints": [ + { + "nodeId": 2, + "index": 0, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 6, + "label": "Appliance" + }, + "specific": { + "key": 1, + "label": "General Appliance" + }, + "mandatorySupportedCCs": [], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 134, + "name": "Version", + "version": 1, + "isSecure": false + }, + { + "id": 144, + "name": "Energy Production", + "version": 1, + "isSecure": false + } + ] + } + ], + "values": [ + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 1, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Library type", + "states": { + "0": "Unknown", + "1": "Static Controller", + "2": "Controller", + "3": "Enhanced Slave", + "4": "Slave", + "5": "Installer", + "6": "Routing Slave", + "7": "Bridge Controller", + "8": "Device under Test", + "9": "N/A", + "10": "AV Remote", + "11": "AV Device" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 1, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 144, + "commandClassName": "Energy Production", + "property": "value", + "propertyKey": 0, + "propertyName": "value", + "propertyKeyName": "0", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Power", + "ccSpecific": { + "parameter": 0, + "scale": 0 + }, + "unit": "W", + "stateful": true, + "secret": false + }, + "value": 1.23 + }, + { + "endpoint": 0, + "commandClass": 144, + "commandClassName": "Energy Production", + "property": "value", + "propertyKey": 1, + "propertyName": "value", + "propertyKeyName": "1", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Production Total", + "ccSpecific": { + "parameter": 1, + "scale": 0 + }, + "unit": "Wh", + "stateful": true, + "secret": false + }, + "value": 1234.56 + }, + { + "endpoint": 0, + "commandClass": 144, + "commandClassName": "Energy Production", + "property": "value", + "propertyKey": 2, + "propertyName": "value", + "propertyKeyName": "2", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Production Today", + "ccSpecific": { + "parameter": 2, + "scale": 0 + }, + "unit": "Wh", + "stateful": true, + "secret": false + }, + "value": 123.45 + }, + { + "endpoint": 0, + "commandClass": 144, + "commandClassName": "Energy Production", + "property": "value", + "propertyKey": 3, + "propertyName": "value", + "propertyKeyName": "3", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Total Time", + "ccSpecific": { + "parameter": 3, + "scale": 0 + }, + "unit": "seconds", + "stateful": true, + "secret": false + }, + "value": 123456 + } + ], + "isFrequentListening": false, + "maxDataRate": 100000, + "supportedDataRates": [40000, 9600, 100000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 6, + "label": "Appliance" + }, + "specific": { + "key": 1, + "label": "General Appliance" + }, + "mandatorySupportedCCs": [], + "mandatoryControlledCCs": [] + }, + "interviewStage": "Complete", + "statistics": { + "commandsTX": 10, + "commandsRX": 7, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 1, + "rtt": 84.8 + }, + "highestSecurityClass": -1, + "isControllerNode": false, + "keepAwake": false +} diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index baf72f5da588..2bedb88884e2 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -35,6 +35,7 @@ from homeassistant.const import ( UnitOfEnergy, UnitOfPower, UnitOfTemperature, + UnitOfTime, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -733,3 +734,54 @@ async def test_statistics_sensors( state = hass.states.get(f"{prefix}{suffix_key}") assert state assert state.state == str(val) + + +ENERGY_PRODUCTION_ENTITY_MAP = { + "energy_production_power": { + "state": 1.23, + "attributes": { + "unit_of_measurement": UnitOfPower.WATT, + "device_class": SensorDeviceClass.POWER, + "state_class": SensorStateClass.MEASUREMENT, + }, + }, + "energy_production_total": { + "state": 1234.56, + "attributes": { + "unit_of_measurement": UnitOfEnergy.WATT_HOUR, + "device_class": SensorDeviceClass.ENERGY, + "state_class": SensorStateClass.TOTAL_INCREASING, + }, + }, + "energy_production_today": { + "state": 123.45, + "attributes": { + "unit_of_measurement": UnitOfEnergy.WATT_HOUR, + "device_class": SensorDeviceClass.ENERGY, + "state_class": SensorStateClass.TOTAL_INCREASING, + }, + }, + "energy_production_time": { + "state": 123456.0, + "attributes": { + "unit_of_measurement": UnitOfTime.SECONDS, + "device_class": SensorDeviceClass.DURATION, + }, + "missing_attributes": ["state_class"], + }, +} + + +async def test_energy_production_sensors( + hass: HomeAssistant, energy_production, client, integration +) -> None: + """Test sensors for Energy Production CC.""" + for entity_id_suffix, state_data in ENERGY_PRODUCTION_ENTITY_MAP.items(): + state = hass.states.get(f"sensor.node_2_{entity_id_suffix}") + assert state + assert state.state == str(state_data["state"]) + for attr, val in state_data["attributes"].items(): + assert state.attributes[attr] == val + + for attr in state_data.get("missing_attributes", []): + assert attr not in state.attributes From b3990d48d2b7a30a2de0c89581245ba65c50c181 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Wed, 31 May 2023 07:31:02 -0400 Subject: [PATCH 741/822] Improve Z-Wave support for Ultraviolet multilevel sensor values (#93848) Improve support for Ultraviolet multilevel sensor values --- homeassistant/components/zwave_js/const.py | 1 + .../components/zwave_js/discovery_data_template.py | 2 ++ homeassistant/components/zwave_js/sensor.py | 7 +++++++ tests/components/zwave_js/test_sensor.py | 7 +++---- 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index 672f8f10e2f4..5ee8b3006037 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -130,6 +130,7 @@ ENTITY_DESC_KEY_PRESSURE = "pressure" ENTITY_DESC_KEY_SIGNAL_STRENGTH = "signal_strength" ENTITY_DESC_KEY_TEMPERATURE = "temperature" ENTITY_DESC_KEY_TARGET_TEMPERATURE = "target_temperature" +ENTITY_DESC_KEY_UV_INDEX = "uv_index" ENTITY_DESC_KEY_MEASUREMENT = "measurement" ENTITY_DESC_KEY_TOTAL_INCREASING = "total_increasing" diff --git a/homeassistant/components/zwave_js/discovery_data_template.py b/homeassistant/components/zwave_js/discovery_data_template.py index e5a19f9bf2ef..7a274df41f24 100644 --- a/homeassistant/components/zwave_js/discovery_data_template.py +++ b/homeassistant/components/zwave_js/discovery_data_template.py @@ -150,6 +150,7 @@ from .const import ( ENTITY_DESC_KEY_TARGET_TEMPERATURE, ENTITY_DESC_KEY_TEMPERATURE, ENTITY_DESC_KEY_TOTAL_INCREASING, + ENTITY_DESC_KEY_UV_INDEX, ENTITY_DESC_KEY_VOLTAGE, ) from .helpers import ZwaveValueID @@ -186,6 +187,7 @@ MULTILEVEL_SENSOR_DEVICE_CLASS_MAP: dict[str, list[MultilevelSensorType]] = { ENTITY_DESC_KEY_SIGNAL_STRENGTH: SIGNAL_STRENGTH_SENSORS, ENTITY_DESC_KEY_TEMPERATURE: TEMPERATURE_SENSORS, ENTITY_DESC_KEY_VOLTAGE: VOLTAGE_SENSORS, + ENTITY_DESC_KEY_UV_INDEX: [MultilevelSensorType.ULTRAVIOLET], } ENERGY_PRODUCTION_UNIT_MAP: dict[str, list[EnergyProductionScaleType]] = { diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 234062111f0f..3bb893c303ad 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -32,6 +32,7 @@ from homeassistant.const import ( LIGHT_LUX, PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + UV_INDEX, EntityCategory, UnitOfElectricCurrent, UnitOfElectricPotential, @@ -73,6 +74,7 @@ from .const import ( ENTITY_DESC_KEY_TARGET_TEMPERATURE, ENTITY_DESC_KEY_TEMPERATURE, ENTITY_DESC_KEY_TOTAL_INCREASING, + ENTITY_DESC_KEY_UV_INDEX, ENTITY_DESC_KEY_VOLTAGE, LOGGER, SERVICE_RESET_METER, @@ -321,6 +323,11 @@ ENTITY_DESCRIPTION_KEY_MAP = { ENTITY_DESC_KEY_TOTAL_INCREASING, state_class=SensorStateClass.TOTAL_INCREASING, ), + ENTITY_DESC_KEY_UV_INDEX: SensorEntityDescription( + ENTITY_DESC_KEY_UV_INDEX, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UV_INDEX, + ), } diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index 2bedb88884e2..bf6ff36d8f0c 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -29,6 +29,7 @@ from homeassistant.const import ( PERCENTAGE, STATE_UNAVAILABLE, STATE_UNKNOWN, + UV_INDEX, EntityCategory, UnitOfElectricCurrent, UnitOfElectricPotential, @@ -90,11 +91,9 @@ async def test_numeric_sensor( assert state assert state.state == "0.0" - # TODO: Add UV_INDEX unit of measurement to this sensor - assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UV_INDEX assert ATTR_DEVICE_CLASS not in state.attributes - # TODO: Add measurement state class to this sensor - assert ATTR_STATE_CLASS not in state.attributes + assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT state = hass.states.get("sensor.hsm200_illuminance") From 0f4dec534c5665c78dfbe6cccc499ee7a5de1638 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Wed, 31 May 2023 08:14:49 -0400 Subject: [PATCH 742/822] Bump ZHA quirks (#93860) --- 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 46fe2ce472a6..6e93fca6042e 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -23,7 +23,7 @@ "bellows==0.35.5", "pyserial==3.5", "pyserial-asyncio==0.6", - "zha-quirks==0.0.99", + "zha-quirks==0.0.100", "zigpy-deconz==0.21.0", "zigpy==0.55.0", "zigpy-xbee==0.18.0", diff --git a/requirements_all.txt b/requirements_all.txt index ccf2c5eae20f..905334319d1b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2734,7 +2734,7 @@ zeroconf==0.63.0 zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.99 +zha-quirks==0.0.100 # homeassistant.components.zhong_hong zhong_hong_hvac==1.0.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 721904d01ba3..2afc6ed87837 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1992,7 +1992,7 @@ zeroconf==0.63.0 zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.99 +zha-quirks==0.0.100 # homeassistant.components.zha zigpy-deconz==0.21.0 From 21771457d10570404e6b6f4640819366c31bf397 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Wed, 31 May 2023 14:22:53 +0200 Subject: [PATCH 743/822] Use library constants for Airzone Cloud diagnostics (#93858) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit airzone_cloud: diagnostics: use constants from library Signed-off-by: Álvaro Fernández Rojas --- .../components/airzone_cloud/diagnostics.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/airzone_cloud/diagnostics.py b/homeassistant/components/airzone_cloud/diagnostics.py index a52fb66ca8f4..a86f95d6187b 100644 --- a/homeassistant/components/airzone_cloud/diagnostics.py +++ b/homeassistant/components/airzone_cloud/diagnostics.py @@ -5,8 +5,14 @@ from collections.abc import Mapping from typing import Any from aioairzone_cloud.const import ( + API_CITY, + API_GROUP_ID, + API_LOCATION_ID, + API_OLD_ID, + API_PIN, API_STAT_AP_MAC, API_STAT_SSID, + API_USER_ID, AZD_WIFI_MAC, RAW_DEVICES_STATUS, RAW_INSTALLATIONS, @@ -22,14 +28,14 @@ from .const import DOMAIN from .coordinator import AirzoneUpdateCoordinator TO_REDACT_API = [ - "_id", - "city", - "group_id", - "location_id", - "pin", - "user_id", + API_CITY, + API_GROUP_ID, + API_LOCATION_ID, + API_OLD_ID, + API_PIN, API_STAT_AP_MAC, API_STAT_SSID, + API_USER_ID, ] TO_REDACT_CONFIG = [ From 676b6ab7062bfb8e0ecf8efb6bfeff8e8fef2e78 Mon Sep 17 00:00:00 2001 From: Yuxin Wang Date: Wed, 31 May 2023 08:25:46 -0400 Subject: [PATCH 744/822] Add support for "days" unit for STESTI sensor in APCUPSD integration (#93844) Add a test case for self test interval --- homeassistant/components/apcupsd/sensor.py | 4 +++- tests/components/apcupsd/__init__.py | 1 + tests/components/apcupsd/test_sensor.py | 10 ++++++++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/apcupsd/sensor.py b/homeassistant/components/apcupsd/sensor.py index 77d8933827db..17168700f662 100644 --- a/homeassistant/components/apcupsd/sensor.py +++ b/homeassistant/components/apcupsd/sensor.py @@ -377,7 +377,6 @@ SENSORS: dict[str, SensorEntityDescription] = { key="stesti", name="UPS Self Test Interval", icon="mdi:information-outline", - state_class=SensorStateClass.TOTAL_INCREASING, ), "timeleft": SensorEntityDescription( key="timeleft", @@ -440,6 +439,9 @@ INFERRED_UNITS = { # "34.6 C Internal". Here we create a fake unit " C Internal" to handle this case. " C Internal": UnitOfTemperature.CELSIUS, " Percent Load Capacity": PERCENTAGE, + # "stesti" field (Self Test Interval) field could report a "days" unit, e.g., + # "7 days", so here we add support for it. + " days": UnitOfTime.DAYS, } diff --git a/tests/components/apcupsd/__init__.py b/tests/components/apcupsd/__init__.py index 7b58013ddbe7..f99b29c7bb7a 100644 --- a/tests/components/apcupsd/__init__.py +++ b/tests/components/apcupsd/__init__.py @@ -43,6 +43,7 @@ MOCK_STATUS: Final = OrderedDict( ("XOFFBATT", "1970-01-01 00:00:00 0000"), ("LASTSTEST", "1970-01-01 00:00:00 0000"), ("SELFTEST", "NO"), + ("STESTI", "7 days"), ("STATFLAG", "0x05000008"), ("SERIALNO", "XXXXXXXXXXXX"), ("BATTDATE", "1970-01-01"), diff --git a/tests/components/apcupsd/test_sensor.py b/tests/components/apcupsd/test_sensor.py index 3cc2f0a5620c..a9f6820faa0c 100644 --- a/tests/components/apcupsd/test_sensor.py +++ b/tests/components/apcupsd/test_sensor.py @@ -11,6 +11,7 @@ from homeassistant.const import ( PERCENTAGE, UnitOfElectricPotential, UnitOfPower, + UnitOfTime, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -56,6 +57,15 @@ async def test_sensor(hass: HomeAssistant) -> None: assert entry assert entry.unique_id == "XXXXXXXXXXXX_battv" + # test a representative time sensor. + state = hass.states.get("sensor.ups_self_test_interval") + assert state + assert state.state == "7" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTime.DAYS + entry = registry.async_get("sensor.ups_self_test_interval") + assert entry + assert entry.unique_id == "XXXXXXXXXXXX_stesti" + # Test a representative percentage sensor. state = hass.states.get("sensor.ups_load") assert state From 1d7498378e58b4836b41159c127a50d9c35453c3 Mon Sep 17 00:00:00 2001 From: Luke Date: Wed, 31 May 2023 08:26:39 -0400 Subject: [PATCH 745/822] Add Status indicator light switch to Roborock (#93842) add status indicator light switch --- homeassistant/components/roborock/strings.json | 3 +++ homeassistant/components/roborock/switch.py | 13 ++++++++++++- tests/components/roborock/test_switch.py | 1 + 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 17b9b12cab76..1a65b636dfc9 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -100,6 +100,9 @@ "switch": { "child_lock": { "name": "Child lock" + }, + "status_indicator": { + "name": "Status indicator light" } }, "vacuum": { diff --git a/homeassistant/components/roborock/switch.py b/homeassistant/components/roborock/switch.py index 0af07af4de58..6971ff9d9003 100644 --- a/homeassistant/components/roborock/switch.py +++ b/homeassistant/components/roborock/switch.py @@ -50,7 +50,18 @@ SWITCH_DESCRIPTIONS: list[RoborockSwitchDescription] = [ translation_key="child_lock", icon="mdi:account-lock", entity_category=EntityCategory.CONFIG, - ) + ), + RoborockSwitchDescription( + set_command=lambda entity, value: entity.send( + RoborockCommand.SET_FLOW_LED_STATUS, {"status": 1 if value else 0} + ), + get_value=lambda data: data.send(RoborockCommand.GET_FLOW_LED_STATUS), + evaluate_value=lambda data: data["status"] == 1, + key="status_indicator", + translation_key="status_indicator", + icon="mdi:alarm-light-outline", + entity_category=EntityCategory.CONFIG, + ), ] diff --git a/tests/components/roborock/test_switch.py b/tests/components/roborock/test_switch.py index 153ddbd8293e..276c2758da4c 100644 --- a/tests/components/roborock/test_switch.py +++ b/tests/components/roborock/test_switch.py @@ -15,6 +15,7 @@ from tests.common import MockConfigEntry ("entity_id"), [ ("switch.roborock_s7_maxv_child_lock"), + ("switch.roborock_s7_maxv_status_indicator_light"), ], ) async def test_update_success( From c8c368340ddef3623ad798768d927876df6a06ed Mon Sep 17 00:00:00 2001 From: Dominik Date: Wed, 31 May 2023 15:17:23 +0200 Subject: [PATCH 746/822] Glances: Improve performance (#93852) * Improve performance * Apply suggestions from code review Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/glances/sensor.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/glances/sensor.py b/homeassistant/components/glances/sensor.py index 4d2546dac587..f4a3f8827491 100644 --- a/homeassistant/components/glances/sensor.py +++ b/homeassistant/components/glances/sensor.py @@ -334,9 +334,9 @@ class GlancesSensor(CoordinatorEntity[GlancesDataUpdateCoordinator], SensorEntit if super().available: return ( not self._numeric_state_expected - or isinstance(self.native_value, (int, float)) - or isinstance(self.native_value, str) - and self.native_value.isnumeric() + or isinstance(value := self.native_value, (int, float)) + or isinstance(value, str) + and value.isnumeric() ) return False From 3cf8ae64c8f845a9059e16f386ab9b652fda2757 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Diego=20Rodr=C3=ADguez=20Royo?= Date: Wed, 31 May 2023 16:08:01 +0200 Subject: [PATCH 747/822] Matter cover position improvements (#92278) --- homeassistant/components/matter/cover.py | 96 ++++++++++++++---------- tests/components/matter/test_cover.py | 86 +++++++++++++++++---- 2 files changed, 130 insertions(+), 52 deletions(-) diff --git a/homeassistant/components/matter/cover.py b/homeassistant/components/matter/cover.py index 9e72b69f3353..61c5d4cd2ff4 100644 --- a/homeassistant/components/matter/cover.py +++ b/homeassistant/components/matter/cover.py @@ -2,6 +2,7 @@ from __future__ import annotations from enum import IntEnum +from math import floor from typing import Any from chip.clusters import Objects as clusters @@ -59,9 +60,18 @@ class MatterCover(MatterEntity, CoverEntity): entity_description: CoverEntityDescription @property - def is_closed(self) -> bool: - """Return true if cover is closed, else False.""" - return self.current_cover_position == 0 + def is_closed(self) -> bool | None: + """Return true if cover is closed, if there is no position report, return None.""" + if not self._entity_info.endpoint.has_attribute( + None, clusters.WindowCovering.Attributes.CurrentPositionLiftPercent100ths + ): + return None + + return ( + self.current_cover_position == 0 + if self.current_cover_position is not None + else None + ) async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover movement.""" @@ -126,37 +136,45 @@ class MatterCover(MatterEntity, CoverEntity): self._attr_is_opening = False self._attr_is_closing = False - # current position is inverted in matter (100 is closed, 0 is open) - current_cover_position = self.get_matter_attribute_value( - clusters.WindowCovering.Attributes.CurrentPositionLiftPercentage - ) - self._attr_current_cover_position = ( - 100 - current_cover_position if current_cover_position is not None else None - ) + if self._entity_info.endpoint.has_attribute( + None, clusters.WindowCovering.Attributes.CurrentPositionLiftPercent100ths + ): + # current position is inverted in matter (100 is closed, 0 is open) + current_cover_position = self.get_matter_attribute_value( + clusters.WindowCovering.Attributes.CurrentPositionLiftPercent100ths + ) + self._attr_current_cover_position = ( + 100 - floor(current_cover_position / 100) + if current_cover_position is not None + else None + ) - LOGGER.debug( - "Current position for %s - raw: %s - corrected: %s", - self.entity_id, - current_cover_position, - self.current_cover_position, - ) + LOGGER.debug( + "Current position for %s - raw: %s - corrected: %s", + self.entity_id, + current_cover_position, + self.current_cover_position, + ) - # current tilt position is inverted in matter (100 is closed, 0 is open) - current_cover_tilt_position = self.get_matter_attribute_value( - clusters.WindowCovering.Attributes.CurrentPositionTiltPercentage - ) - self._attr_current_cover_tilt_position = ( - 100 - current_cover_tilt_position - if current_cover_tilt_position is not None - else None - ) + if self._entity_info.endpoint.has_attribute( + None, clusters.WindowCovering.Attributes.CurrentPositionTiltPercent100ths + ): + # current tilt position is inverted in matter (100 is closed, 0 is open) + current_cover_tilt_position = self.get_matter_attribute_value( + clusters.WindowCovering.Attributes.CurrentPositionTiltPercent100ths + ) + self._attr_current_cover_tilt_position = ( + 100 - floor(current_cover_tilt_position / 100) + if current_cover_tilt_position is not None + else None + ) - LOGGER.debug( - "Current tilt position for %s - raw: %s - corrected: %s", - self.entity_id, - current_cover_tilt_position, - self.current_cover_tilt_position, - ) + LOGGER.debug( + "Current tilt position for %s - raw: %s - corrected: %s", + self.entity_id, + current_cover_tilt_position, + self.current_cover_tilt_position, + ) # map matter type to HA deviceclass device_type: clusters.WindowCovering.Enums.Type = ( @@ -188,8 +206,8 @@ DISCOVERY_SCHEMAS = [ clusters.WindowCovering.Attributes.Type, ), absent_attributes=( - clusters.WindowCovering.Attributes.CurrentPositionLiftPercentage, - clusters.WindowCovering.Attributes.CurrentPositionTiltPercentage, + clusters.WindowCovering.Attributes.CurrentPositionLiftPercent100ths, + clusters.WindowCovering.Attributes.CurrentPositionTiltPercent100ths, ), ), MatterDiscoverySchema( @@ -199,10 +217,10 @@ DISCOVERY_SCHEMAS = [ required_attributes=( clusters.WindowCovering.Attributes.OperationalStatus, clusters.WindowCovering.Attributes.Type, - clusters.WindowCovering.Attributes.CurrentPositionLiftPercentage, + clusters.WindowCovering.Attributes.CurrentPositionLiftPercent100ths, ), absent_attributes=( - clusters.WindowCovering.Attributes.CurrentPositionTiltPercentage, + clusters.WindowCovering.Attributes.CurrentPositionTiltPercent100ths, ), ), MatterDiscoverySchema( @@ -212,10 +230,10 @@ DISCOVERY_SCHEMAS = [ required_attributes=( clusters.WindowCovering.Attributes.OperationalStatus, clusters.WindowCovering.Attributes.Type, - clusters.WindowCovering.Attributes.CurrentPositionTiltPercentage, + clusters.WindowCovering.Attributes.CurrentPositionTiltPercent100ths, ), absent_attributes=( - clusters.WindowCovering.Attributes.CurrentPositionLiftPercentage, + clusters.WindowCovering.Attributes.CurrentPositionLiftPercent100ths, ), ), MatterDiscoverySchema( @@ -227,8 +245,8 @@ DISCOVERY_SCHEMAS = [ required_attributes=( clusters.WindowCovering.Attributes.OperationalStatus, clusters.WindowCovering.Attributes.Type, - clusters.WindowCovering.Attributes.CurrentPositionLiftPercentage, - clusters.WindowCovering.Attributes.CurrentPositionTiltPercentage, + clusters.WindowCovering.Attributes.CurrentPositionLiftPercent100ths, + clusters.WindowCovering.Attributes.CurrentPositionTiltPercent100ths, ), ), ] diff --git a/tests/components/matter/test_cover.py b/tests/components/matter/test_cover.py index bbbd0751e4a7..d409983307ff 100644 --- a/tests/components/matter/test_cover.py +++ b/tests/components/matter/test_cover.py @@ -1,4 +1,5 @@ """Test Matter covers.""" +from math import floor from unittest.mock import MagicMock, call from chip.clusters import Objects as clusters @@ -177,6 +178,14 @@ async def test_cover_lift_only( matter_client, ) + set_node_attribute(window_covering, 1, 258, 14, None) + set_node_attribute(window_covering, 1, 258, 10, 0b000000) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get(entity_id) + assert state + assert state.state == "unknown" + set_node_attribute(window_covering, 1, 258, 65529, [0, 1, 2]) await trigger_subscription_callback(hass, matter_client) @@ -224,17 +233,17 @@ async def test_cover_position_aware_lift( ) assert state.attributes["supported_features"] & mask == mask - for position in (0, 99): - set_node_attribute(window_covering, 1, 258, 8, position) + for position in (0, 9999): + set_node_attribute(window_covering, 1, 258, 14, position) set_node_attribute(window_covering, 1, 258, 10, 0b000000) await trigger_subscription_callback(hass, matter_client) state = hass.states.get(entity_id) assert state - assert state.attributes["current_position"] == 100 - position + assert state.attributes["current_position"] == 100 - floor(position / 100) assert state.state == STATE_OPEN - set_node_attribute(window_covering, 1, 258, 8, 100) + set_node_attribute(window_covering, 1, 258, 14, 10000) set_node_attribute(window_covering, 1, 258, 10, 0b000000) await trigger_subscription_callback(hass, matter_client) @@ -377,14 +386,16 @@ async def test_cover_position_aware_tilt( ) assert state.attributes["supported_features"] & mask == mask - for tilt_position in (0, 99, 100): - set_node_attribute(window_covering, 1, 258, 9, tilt_position) + for tilt_position in (0, 9999, 10000): + set_node_attribute(window_covering, 1, 258, 15, tilt_position) set_node_attribute(window_covering, 1, 258, 10, 0b000000) await trigger_subscription_callback(hass, matter_client) state = hass.states.get(entity_id) assert state - assert state.attributes["current_tilt_position"] == 100 - tilt_position + assert state.attributes["current_tilt_position"] == 100 - floor( + tilt_position / 100 + ) async def test_cover_full_features( @@ -411,8 +422,8 @@ async def test_cover_full_features( ) assert state.attributes["supported_features"] & mask == mask - set_node_attribute(window_covering, 1, 258, 8, 100) - set_node_attribute(window_covering, 1, 258, 9, 100) + set_node_attribute(window_covering, 1, 258, 14, 10000) + set_node_attribute(window_covering, 1, 258, 15, 10000) set_node_attribute(window_covering, 1, 258, 10, 0b000000) await trigger_subscription_callback(hass, matter_client) @@ -420,8 +431,8 @@ async def test_cover_full_features( assert state assert state.state == STATE_CLOSED - set_node_attribute(window_covering, 1, 258, 8, 50) - set_node_attribute(window_covering, 1, 258, 9, 100) + set_node_attribute(window_covering, 1, 258, 14, 5000) + set_node_attribute(window_covering, 1, 258, 15, 10000) set_node_attribute(window_covering, 1, 258, 10, 0b000000) await trigger_subscription_callback(hass, matter_client) @@ -429,11 +440,60 @@ async def test_cover_full_features( assert state assert state.state == STATE_OPEN - set_node_attribute(window_covering, 1, 258, 8, 100) - set_node_attribute(window_covering, 1, 258, 9, 50) + set_node_attribute(window_covering, 1, 258, 14, 10000) + set_node_attribute(window_covering, 1, 258, 15, 5000) set_node_attribute(window_covering, 1, 258, 10, 0b000000) await trigger_subscription_callback(hass, matter_client) state = hass.states.get(entity_id) assert state assert state.state == STATE_CLOSED + + set_node_attribute(window_covering, 1, 258, 14, 5000) + set_node_attribute(window_covering, 1, 258, 15, 5000) + set_node_attribute(window_covering, 1, 258, 10, 0b000000) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OPEN + + set_node_attribute(window_covering, 1, 258, 14, 5000) + set_node_attribute(window_covering, 1, 258, 15, None) + set_node_attribute(window_covering, 1, 258, 10, 0b000000) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OPEN + + set_node_attribute(window_covering, 1, 258, 14, None) + set_node_attribute(window_covering, 1, 258, 15, 5000) + set_node_attribute(window_covering, 1, 258, 10, 0b000000) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get(entity_id) + assert state + assert state.state == "unknown" + + set_node_attribute(window_covering, 1, 258, 14, 10000) + set_node_attribute(window_covering, 1, 258, 15, None) + set_node_attribute(window_covering, 1, 258, 10, 0b000000) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_CLOSED + + set_node_attribute(window_covering, 1, 258, 14, None) + set_node_attribute(window_covering, 1, 258, 15, 10000) + set_node_attribute(window_covering, 1, 258, 10, 0b000000) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get(entity_id) + assert state + assert state.state == "unknown" + + set_node_attribute(window_covering, 1, 258, 14, None) + set_node_attribute(window_covering, 1, 258, 15, None) + set_node_attribute(window_covering, 1, 258, 10, 0b000000) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get(entity_id) + assert state + assert state.state == "unknown" From b03dbeaed5d0c70ea774e9f991ad0941334d003e Mon Sep 17 00:00:00 2001 From: Mark Kennedy Date: Wed, 31 May 2023 15:57:45 +0100 Subject: [PATCH 748/822] Block legacy get-mac in preference of getmac (#87712) Co-authored-by: GnatorX --- homeassistant/components/vilfo/manifest.json | 2 +- homeassistant/package_constraints.txt | 4 +++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/gen_requirements_all.py | 4 +++ tests/components/vilfo/test_config_flow.py | 34 ++++++++++++++++---- 6 files changed, 39 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/vilfo/manifest.json b/homeassistant/components/vilfo/manifest.json index 4b82bba83cd9..850d4fecb27a 100644 --- a/homeassistant/components/vilfo/manifest.json +++ b/homeassistant/components/vilfo/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/vilfo", "iot_class": "local_polling", "loggers": ["vilfo"], - "requirements": ["vilfo-api-client==0.3.2"] + "requirements": ["vilfo-api-client==0.4.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 1361c41d74b7..70dbe4199fdd 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -179,3 +179,7 @@ pysnmp==1000000000.0.0 # pyminiaudio 1.58 is missing files in the package # https://github.com/irmen/pyminiaudio/issues/67 miniaudio==1.57 + +# 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 diff --git a/requirements_all.txt b/requirements_all.txt index 905334319d1b..92449d083b7e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2604,7 +2604,7 @@ velbus-aio==2023.2.0 venstarcolortouch==0.19 # homeassistant.components.vilfo -vilfo-api-client==0.3.2 +vilfo-api-client==0.4.1 # homeassistant.components.voip voip-utils==0.0.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2afc6ed87837..dffbc65c8126 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1892,7 +1892,7 @@ velbus-aio==2023.2.0 venstarcolortouch==0.19 # homeassistant.components.vilfo -vilfo-api-client==0.3.2 +vilfo-api-client==0.4.1 # homeassistant.components.voip voip-utils==0.0.7 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 45009dedc7cf..b51ddb463074 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -183,6 +183,10 @@ pysnmp==1000000000.0.0 # pyminiaudio 1.58 is missing files in the package # https://github.com/irmen/pyminiaudio/issues/67 miniaudio==1.57 + +# 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 """ IGNORE_PRE_COMMIT_HOOK_ID = ( diff --git a/tests/components/vilfo/test_config_flow.py b/tests/components/vilfo/test_config_flow.py index c180f154a272..0aa59c9271f4 100644 --- a/tests/components/vilfo/test_config_flow.py +++ b/tests/components/vilfo/test_config_flow.py @@ -13,6 +13,7 @@ async def test_form(hass: HomeAssistant) -> None: """Test we get the form.""" mock_mac = "FF-00-00-00-00-00" + firmware_version = "1.1.0" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -21,7 +22,11 @@ async def test_form(hass: HomeAssistant) -> None: with patch("vilfo.Client.ping", return_value=None), patch( "vilfo.Client.get_board_information", return_value=None - ), patch("vilfo.Client.resolve_mac_address", return_value=mock_mac), patch( + ), patch( + "vilfo.Client.resolve_firmware_version", return_value=firmware_version + ), patch( + "vilfo.Client.resolve_mac_address", return_value=mock_mac + ), patch( "homeassistant.components.vilfo.async_setup_entry" ) as mock_setup_entry: result2 = await hass.config_entries.flow.async_configure( @@ -106,11 +111,15 @@ async def test_form_already_configured(hass: HomeAssistant) -> None: first_flow_result1 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - + firmware_version = "1.1.0" with patch("vilfo.Client.ping", return_value=None), patch( "vilfo.Client.get_board_information", return_value=None, - ), patch("vilfo.Client.resolve_mac_address", return_value=None): + ), patch( + "vilfo.Client.resolve_firmware_version", return_value=firmware_version + ), patch( + "vilfo.Client.resolve_mac_address", return_value=None + ): first_flow_result2 = await hass.config_entries.flow.async_configure( first_flow_result1["flow_id"], {CONF_HOST: "testadmin.vilfo.com", CONF_ACCESS_TOKEN: "test-token"}, @@ -123,7 +132,11 @@ async def test_form_already_configured(hass: HomeAssistant) -> None: with patch("vilfo.Client.ping", return_value=None), patch( "vilfo.Client.get_board_information", return_value=None, - ), patch("vilfo.Client.resolve_mac_address", return_value=None): + ), patch( + "vilfo.Client.resolve_firmware_version", return_value=firmware_version + ), patch( + "vilfo.Client.resolve_mac_address", return_value=None + ): second_flow_result2 = await hass.config_entries.flow.async_configure( second_flow_result1["flow_id"], {CONF_HOST: "testadmin.vilfo.com", CONF_ACCESS_TOKEN: "test-token"}, @@ -158,10 +171,15 @@ async def test_validate_input_returns_data(hass: HomeAssistant) -> None: mock_data_with_ip = {"host": "192.168.0.1", "access_token": "test-token"} mock_data_with_ipv6 = {"host": "2001:db8::1428:57ab", "access_token": "test-token"} mock_mac = "FF-00-00-00-00-00" + firmware_version = "1.1.0" with patch("vilfo.Client.ping", return_value=None), patch( "vilfo.Client.get_board_information", return_value=None - ), patch("vilfo.Client.resolve_mac_address", return_value=None): + ), patch( + "vilfo.Client.resolve_firmware_version", return_value=firmware_version + ), patch( + "vilfo.Client.resolve_mac_address", return_value=None + ): result = await hass.components.vilfo.config_flow.validate_input( hass, data=mock_data ) @@ -173,7 +191,11 @@ async def test_validate_input_returns_data(hass: HomeAssistant) -> None: with patch("vilfo.Client.ping", return_value=None), patch( "vilfo.Client.get_board_information", return_value=None - ), patch("vilfo.Client.resolve_mac_address", return_value=mock_mac): + ), patch( + "vilfo.Client.resolve_firmware_version", return_value=firmware_version + ), patch( + "vilfo.Client.resolve_mac_address", return_value=mock_mac + ): result2 = await hass.components.vilfo.config_flow.validate_input( hass, data=mock_data ) From c3a3ddcfa4470538ebadc52f5fa4ff3586dddf13 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Wed, 31 May 2023 17:00:19 +0200 Subject: [PATCH 749/822] Standardize spelling of TTS and STT (#93857) * Standardize spelling of TTS and STT * Apply suggestions from code review * Update homeassistant/components/tts/media_source.py --------- Co-authored-by: Paulus Schoutsen --- .../components/assist_pipeline/error.py | 4 +- .../components/assist_pipeline/pipeline.py | 42 +++++++++---------- .../assist_pipeline/websocket_api.py | 2 +- homeassistant/components/cloud/tts.py | 2 +- homeassistant/components/demo/stt.py | 2 +- homeassistant/components/demo/tts.py | 2 +- .../components/google_translate/manifest.json | 2 +- homeassistant/components/stt/__init__.py | 2 +- homeassistant/components/stt/legacy.py | 6 +-- homeassistant/components/stt/manifest.json | 2 +- homeassistant/components/tts/legacy.py | 4 +- homeassistant/components/tts/manifest.json | 2 +- homeassistant/components/tts/media_source.py | 2 +- homeassistant/components/wyoming/stt.py | 6 +-- homeassistant/components/wyoming/tts.py | 6 +-- homeassistant/config.py | 2 +- homeassistant/generated/integrations.json | 2 +- script/microsoft_tts.py | 2 +- .../assist_pipeline/test_websocket.py | 10 ++--- tests/components/microsoft/__init__.py | 2 +- tests/components/microsoft/test_tts.py | 2 +- tests/components/stt/test_init.py | 2 +- tests/components/stt/test_legacy.py | 2 +- tests/components/tts/test_legacy.py | 2 +- tests/components/tts/test_media_source.py | 2 +- 25 files changed, 57 insertions(+), 57 deletions(-) diff --git a/homeassistant/components/assist_pipeline/error.py b/homeassistant/components/assist_pipeline/error.py index fa26d916eeb7..c5ffdcaf2d34 100644 --- a/homeassistant/components/assist_pipeline/error.py +++ b/homeassistant/components/assist_pipeline/error.py @@ -19,7 +19,7 @@ class PipelineNotFound(PipelineError): class SpeechToTextError(PipelineError): - """Error in speech to text portion of pipeline.""" + """Error in speech-to-text portion of pipeline.""" class IntentRecognitionError(PipelineError): @@ -27,4 +27,4 @@ class IntentRecognitionError(PipelineError): class TextToSpeechError(PipelineError): - """Error in text to speech portion of pipeline.""" + """Error in text-to-speech portion of pipeline.""" diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 6a4bbdf61e69..c169ab657b86 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -125,7 +125,7 @@ async def _async_resolve_default_pipeline_settings( stt_language = stt_languages[0] else: _LOGGER.debug( - "Speech to text engine '%s' does not support language '%s'", + "Speech-to-text engine '%s' does not support language '%s'", stt_engine_id, pipeline_language, ) @@ -152,7 +152,7 @@ async def _async_resolve_default_pipeline_settings( tts_voice = tts_voices[0].voice_id else: _LOGGER.debug( - "Text to speech engine '%s' does not support language '%s'", + "Text-to-speech engine '%s' does not support language '%s'", tts_engine_id, pipeline_language, ) @@ -387,7 +387,7 @@ class PipelineRun: ) async def prepare_speech_to_text(self, metadata: stt.SpeechMetadata) -> None: - """Prepare speech to text.""" + """Prepare speech-to-text.""" # pipeline.stt_engine can't be None or this function is not called stt_provider = stt.async_get_speech_to_text_engine( self.hass, @@ -398,7 +398,7 @@ class PipelineRun: engine = self.pipeline.stt_engine raise SpeechToTextError( code="stt-provider-missing", - message=f"No speech to text provider for: {engine}", + message=f"No speech-to-text provider for: {engine}", ) metadata.language = self.pipeline.stt_language or self.language @@ -419,7 +419,7 @@ class PipelineRun: metadata: stt.SpeechMetadata, stream: AsyncIterable[bytes], ) -> str: - """Run speech to text portion of pipeline. Returns the spoken text.""" + """Run speech-to-text portion of pipeline. Returns the spoken text.""" if isinstance(self.stt_provider, stt.Provider): engine = self.stt_provider.name else: @@ -441,10 +441,10 @@ class PipelineRun: metadata, stream ) except Exception as src_error: - _LOGGER.exception("Unexpected error during speech to text") + _LOGGER.exception("Unexpected error during speech-to-text") raise SpeechToTextError( code="stt-stream-failed", - message="Unexpected error during speech to text", + message="Unexpected error during speech-to-text", ) from src_error _LOGGER.debug("speech-to-text result %s", result) @@ -452,7 +452,7 @@ class PipelineRun: if result.result != stt.SpeechResultState.SUCCESS: raise SpeechToTextError( code="stt-stream-failed", - message="Speech to text failed", + message="speech-to-text failed", ) if not result.text: @@ -541,7 +541,7 @@ class PipelineRun: return speech async def prepare_text_to_speech(self) -> None: - """Prepare text to speech.""" + """Prepare text-to-speech.""" # pipeline.tts_engine can't be None or this function is not called engine = cast(str, self.pipeline.tts_engine) @@ -562,13 +562,13 @@ class PipelineRun: except HomeAssistantError as err: raise TextToSpeechError( code="tts-not-supported", - message=f"Text to speech engine '{engine}' not found", + message=f"Text-to-speech engine '{engine}' not found", ) from err if not options_supported: raise TextToSpeechError( code="tts-not-supported", message=( - f"Text to speech engine {engine} " + f"Text-to-speech engine {engine} " f"does not support language {self.pipeline.tts_language} or options {tts_options}" ), ) @@ -577,7 +577,7 @@ class PipelineRun: self.tts_options = tts_options async def text_to_speech(self, tts_input: str) -> str: - """Run text to speech portion of pipeline. Returns URL of TTS audio.""" + """Run text-to-speech portion of pipeline. Returns URL of TTS audio.""" self.process_event( PipelineEvent( PipelineEventType.TTS_START, @@ -605,10 +605,10 @@ class PipelineRun: None, ) except Exception as src_error: - _LOGGER.exception("Unexpected error during text to speech") + _LOGGER.exception("Unexpected error during text-to-speech") raise TextToSpeechError( code="tts-failed", - message="Unexpected error during text to speech", + message="Unexpected error during text-to-speech", ) from src_error _LOGGER.debug("TTS result %s", tts_media) @@ -644,7 +644,7 @@ class PipelineInput: """Input for conversation agent. Required when start_stage = intent.""" tts_input: str | None = None - """Input for text to speech. Required when start_stage = tts.""" + """Input for text-to-speech. Required when start_stage = tts.""" conversation_id: str | None = None @@ -654,7 +654,7 @@ class PipelineInput: current_stage = self.run.start_stage try: - # Speech to text + # speech-to-text intent_input = self.intent_input if current_stage == PipelineStage.STT: assert self.stt_metadata is not None @@ -696,15 +696,15 @@ class PipelineInput: if self.run.start_stage == PipelineStage.STT: if self.run.pipeline.stt_engine is None: raise PipelineRunValidationError( - "the pipeline does not support speech to text" + "the pipeline does not support speech-to-text" ) if self.stt_metadata is None: raise PipelineRunValidationError( - "stt_metadata is required for speech to text" + "stt_metadata is required for speech-to-text" ) if self.stt_stream is None: raise PipelineRunValidationError( - "stt_stream is required for speech to text" + "stt_stream is required for speech-to-text" ) elif self.run.start_stage == PipelineStage.INTENT: if self.intent_input is None: @@ -714,12 +714,12 @@ class PipelineInput: elif self.run.start_stage == PipelineStage.TTS: if self.tts_input is None: raise PipelineRunValidationError( - "tts_input is required for text to speech" + "tts_input is required for text-to-speech" ) if self.run.end_stage == PipelineStage.TTS: if self.run.pipeline.tts_engine is None: raise PipelineRunValidationError( - "the pipeline does not support text to speech" + "the pipeline does not support text-to-speech" ) start_stage_index = PIPELINE_STAGE_ORDER.index(self.run.start_stage) diff --git a/homeassistant/components/assist_pipeline/websocket_api.py b/homeassistant/components/assist_pipeline/websocket_api.py index 6c1dbe3dbce9..a4afbe9266ab 100644 --- a/homeassistant/components/assist_pipeline/websocket_api.py +++ b/homeassistant/components/assist_pipeline/websocket_api.py @@ -151,7 +151,7 @@ async def websocket_run( # Input to conversation agent input_args["intent_input"] = msg["input"]["text"] elif start_stage == PipelineStage.TTS: - # Input to text to speech system + # Input to text-to-speech system input_args["tts_input"] = msg["input"]["text"] input_args["run"] = PipelineRun( diff --git a/homeassistant/components/cloud/tts.py b/homeassistant/components/cloud/tts.py index 480ca06bf77c..88f24d1290f7 100644 --- a/homeassistant/components/cloud/tts.py +++ b/homeassistant/components/cloud/tts.py @@ -1,4 +1,4 @@ -"""Support for the cloud for text to speech service.""" +"""Support for the cloud for text-to-speech service.""" from __future__ import annotations import logging diff --git a/homeassistant/components/demo/stt.py b/homeassistant/components/demo/stt.py index 07a844c048cc..6458bf473975 100644 --- a/homeassistant/components/demo/stt.py +++ b/homeassistant/components/demo/stt.py @@ -1,4 +1,4 @@ -"""Support for the demo for speech to text service.""" +"""Support for the demo for speech-to-text service.""" from __future__ import annotations from collections.abc import AsyncIterable diff --git a/homeassistant/components/demo/tts.py b/homeassistant/components/demo/tts.py index 43138f888e98..dfc8d7d7efb1 100644 --- a/homeassistant/components/demo/tts.py +++ b/homeassistant/components/demo/tts.py @@ -1,4 +1,4 @@ -"""Support for the demo for text to speech service.""" +"""Support for the demo for text-to-speech service.""" from __future__ import annotations import os diff --git a/homeassistant/components/google_translate/manifest.json b/homeassistant/components/google_translate/manifest.json index 5321d13c5d61..504925a4667f 100644 --- a/homeassistant/components/google_translate/manifest.json +++ b/homeassistant/components/google_translate/manifest.json @@ -1,6 +1,6 @@ { "domain": "google_translate", - "name": "Google Translate Text-to-Speech", + "name": "Google Translate text-to-speech", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/google_translate", "iot_class": "cloud_push", diff --git a/homeassistant/components/stt/__init__.py b/homeassistant/components/stt/__init__.py index 5e34a567c9ef..516cd4ddea10 100644 --- a/homeassistant/components/stt/__init__.py +++ b/homeassistant/components/stt/__init__.py @@ -413,7 +413,7 @@ def _metadata_from_header(request: web.Request) -> SpeechMetadata: def websocket_list_engines( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict ) -> None: - """List speech to text engines and, optionally, if they support a given language.""" + """List speech-to-text engines and, optionally, if they support a given language.""" component: EntityComponent[SpeechToTextEntity] = hass.data[DOMAIN] legacy_providers: dict[str, Provider] = hass.data[DATA_PROVIDERS] diff --git a/homeassistant/components/stt/legacy.py b/homeassistant/components/stt/legacy.py index f2a5854e5678..9ee3f0bb8ad4 100644 --- a/homeassistant/components/stt/legacy.py +++ b/homeassistant/components/stt/legacy.py @@ -1,4 +1,4 @@ -"""Handle legacy speech to text platforms.""" +"""Handle legacy speech-to-text platforms.""" from __future__ import annotations from abc import ABC, abstractmethod @@ -51,7 +51,7 @@ def async_get_provider( def async_setup_legacy( hass: HomeAssistant, config: ConfigType ) -> list[Coroutine[Any, Any, None]]: - """Set up legacy speech to text providers.""" + """Set up legacy speech-to-text providers.""" providers = hass.data[DATA_PROVIDERS] = {} async def async_setup_platform(p_type, p_config=None, discovery_info=None): @@ -61,7 +61,7 @@ def async_setup_legacy( platform = await async_prepare_setup_platform(hass, config, DOMAIN, p_type) if platform is None: - _LOGGER.error("Unknown speech to text platform specified") + _LOGGER.error("Unknown speech-to-text platform specified") return try: diff --git a/homeassistant/components/stt/manifest.json b/homeassistant/components/stt/manifest.json index 73eb0fa4c079..53bb7fa19379 100644 --- a/homeassistant/components/stt/manifest.json +++ b/homeassistant/components/stt/manifest.json @@ -1,6 +1,6 @@ { "domain": "stt", - "name": "Speech-to-Text (STT)", + "name": "Speech-to-text (STT)", "codeowners": ["@home-assistant/core", "@pvizeli"], "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/stt", diff --git a/homeassistant/components/tts/legacy.py b/homeassistant/components/tts/legacy.py index e4e146b72dec..619c93746220 100644 --- a/homeassistant/components/tts/legacy.py +++ b/homeassistant/components/tts/legacy.py @@ -112,7 +112,7 @@ SCHEMA_SERVICE_SAY = vol.Schema( async def async_setup_legacy( hass: HomeAssistant, config: ConfigType ) -> list[Coroutine[Any, Any, None]]: - """Set up legacy text to speech providers.""" + """Set up legacy text-to-speech providers.""" tts: SpeechManager = hass.data[DATA_TTS_MANAGER] # Load service descriptions from tts/services.yaml @@ -132,7 +132,7 @@ async def async_setup_legacy( platform = await async_prepare_setup_platform(hass, config, DOMAIN, p_type) if platform is None: - _LOGGER.error("Unknown text to speech platform specified") + _LOGGER.error("Unknown text-to-speech platform specified") return try: diff --git a/homeassistant/components/tts/manifest.json b/homeassistant/components/tts/manifest.json index 741edbc4cefb..249e427c5913 100644 --- a/homeassistant/components/tts/manifest.json +++ b/homeassistant/components/tts/manifest.json @@ -1,6 +1,6 @@ { "domain": "tts", - "name": "Text-to-Speech (TTS)", + "name": "Text-to-speech (TTS)", "after_dependencies": ["media_player"], "codeowners": ["@home-assistant/core", "@pvizeli"], "dependencies": ["http"], diff --git a/homeassistant/components/tts/media_source.py b/homeassistant/components/tts/media_source.py index 34dc3822e93c..9fc0d40dae08 100644 --- a/homeassistant/components/tts/media_source.py +++ b/homeassistant/components/tts/media_source.py @@ -104,7 +104,7 @@ def media_source_id_to_kwargs(media_source_id: str) -> MediaSourceOptions: class TTSMediaSource(MediaSource): """Provide text-to-speech providers as media sources.""" - name: str = "Text to Speech" + name: str = "Text-to-speech" def __init__(self, hass: HomeAssistant) -> None: """Initialize TTSMediaSource.""" diff --git a/homeassistant/components/wyoming/stt.py b/homeassistant/components/wyoming/stt.py index 8d3f65345027..3f5487881a32 100644 --- a/homeassistant/components/wyoming/stt.py +++ b/homeassistant/components/wyoming/stt.py @@ -1,4 +1,4 @@ -"""Support for Wyoming speech to text services.""" +"""Support for Wyoming speech-to-text services.""" from collections.abc import AsyncIterable import logging @@ -23,7 +23,7 @@ async def async_setup_entry( config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up Wyoming speech to text.""" + """Set up Wyoming speech-to-text.""" service: WyomingService = hass.data[DOMAIN][config_entry.entry_id] async_add_entities( [ @@ -33,7 +33,7 @@ async def async_setup_entry( class WyomingSttProvider(stt.SpeechToTextEntity): - """Wyoming speech to text provider.""" + """Wyoming speech-to-text provider.""" def __init__( self, diff --git a/homeassistant/components/wyoming/tts.py b/homeassistant/components/wyoming/tts.py index 42439280a3b5..0fc7bf5e6c4c 100644 --- a/homeassistant/components/wyoming/tts.py +++ b/homeassistant/components/wyoming/tts.py @@ -1,4 +1,4 @@ -"""Support for Wyoming text to speech services.""" +"""Support for Wyoming text-to-speech services.""" from collections import defaultdict import io import logging @@ -25,7 +25,7 @@ async def async_setup_entry( config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up Wyoming speech to text.""" + """Set up Wyoming speech-to-text.""" service: WyomingService = hass.data[DOMAIN][config_entry.entry_id] async_add_entities( [ @@ -35,7 +35,7 @@ async def async_setup_entry( class WyomingTtsProvider(tts.TextToSpeechEntity): - """Wyoming text to speech provider.""" + """Wyoming text-to-speech provider.""" def __init__( self, diff --git a/homeassistant/config.py b/homeassistant/config.py index 0a5da91d9421..4ef1da1e6470 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -96,7 +96,7 @@ default_config: frontend: themes: !include_dir_merge_named themes -# Text to speech +# Text-to-speech tts: - platform: google_translate diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index c91d7f888c39..6418e93aa033 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2080,7 +2080,7 @@ "integration_type": "hub", "config_flow": false, "iot_class": "cloud_push", - "name": "Google Translate Text-to-Speech" + "name": "Google Translate text-to-speech" }, "google_travel_time": { "integration_type": "hub", diff --git a/script/microsoft_tts.py b/script/microsoft_tts.py index 128c287345c3..bc0b577c97c5 100644 --- a/script/microsoft_tts.py +++ b/script/microsoft_tts.py @@ -1,4 +1,4 @@ -"""Helper script to update supported languages for Microsoft Text-to-Speech (TTS).""" +"""Helper script to update supported languages for Microsoft text-to-speech (TTS).""" from pathlib import Path from lxml import html diff --git a/tests/components/assist_pipeline/test_websocket.py b/tests/components/assist_pipeline/test_websocket.py index 95e2c33ef5b2..230440733687 100644 --- a/tests/components/assist_pipeline/test_websocket.py +++ b/tests/components/assist_pipeline/test_websocket.py @@ -132,7 +132,7 @@ async def test_audio_pipeline( assert msg["event"]["data"] == snapshot events.append(msg["event"]) - # text to speech + # text-to-speech msg = await client.receive_json() assert msg["event"]["type"] == "tts-start" assert msg["event"]["data"] == snapshot @@ -532,7 +532,7 @@ async def test_tts_failed( init_components, snapshot: SnapshotAssertion, ) -> None: - """Test pipeline run with text to speech error.""" + """Test pipeline run with text-to-speech error.""" events = [] client = await hass_ws_client(hass) @@ -595,7 +595,7 @@ async def test_tts_provider_missing( mock_tts_provider, snapshot: SnapshotAssertion, ) -> None: - """Test pipeline run with text to speech error.""" + """Test pipeline run with text-to-speech error.""" client = await hass_ws_client(hass) with patch( @@ -624,7 +624,7 @@ async def test_tts_provider_bad_options( mock_tts_provider, snapshot: SnapshotAssertion, ) -> None: - """Test pipeline run with text to speech error.""" + """Test pipeline run with text-to-speech error.""" client = await hass_ws_client(hass) with patch( @@ -1227,7 +1227,7 @@ async def test_audio_pipeline_debug( assert msg["event"]["data"] == snapshot events.append(msg["event"]) - # text to speech + # text-to-speech msg = await client.receive_json() assert msg["event"]["type"] == "tts-start" assert msg["event"]["data"] == snapshot diff --git a/tests/components/microsoft/__init__.py b/tests/components/microsoft/__init__.py index 5c1ed8dad090..10e98be7c01d 100644 --- a/tests/components/microsoft/__init__.py +++ b/tests/components/microsoft/__init__.py @@ -1 +1 @@ -"""Tests for the Microsoft Text-to-Speech component.""" +"""Tests for the Microsoft text-to-speech component.""" diff --git a/tests/components/microsoft/test_tts.py b/tests/components/microsoft/test_tts.py index dd183ae7120e..9684d1aa7d5a 100644 --- a/tests/components/microsoft/test_tts.py +++ b/tests/components/microsoft/test_tts.py @@ -1,4 +1,4 @@ -"""Tests for Microsoft Text-to-Speech.""" +"""Tests for Microsoft text-to-speech.""" from unittest.mock import patch from pycsspeechtts import pycsspeechtts diff --git a/tests/components/stt/test_init.py b/tests/components/stt/test_init.py index 5a7e93a72a27..4100df94b9ee 100644 --- a/tests/components/stt/test_init.py +++ b/tests/components/stt/test_init.py @@ -392,7 +392,7 @@ async def test_ws_list_engines( setup: MockProvider | MockProviderEntity, engine_id: str, ) -> None: - """Test listing speech to text engines.""" + """Test listing speech-to-text engines.""" client = await hass_ws_client() await client.send_json_auto_id({"type": "stt/engine/list"}) diff --git a/tests/components/stt/test_legacy.py b/tests/components/stt/test_legacy.py index a95a1f0f6f49..7176b866b002 100644 --- a/tests/components/stt/test_legacy.py +++ b/tests/components/stt/test_legacy.py @@ -26,7 +26,7 @@ async def test_invalid_platform( ) await hass.async_block_till_done() - assert "Unknown speech to text platform specified" in caplog.text + assert "Unknown speech-to-text platform specified" in caplog.text async def test_platform_setup_with_error( diff --git a/tests/components/tts/test_legacy.py b/tests/components/tts/test_legacy.py index aa1efaf6f669..26b7c2397b48 100644 --- a/tests/components/tts/test_legacy.py +++ b/tests/components/tts/test_legacy.py @@ -71,7 +71,7 @@ async def test_invalid_platform( ) await hass.async_block_till_done() - assert "Unknown text to speech platform specified" in caplog.text + assert "Unknown text-to-speech platform specified" in caplog.text async def test_platform_setup_without_provider( diff --git a/tests/components/tts/test_media_source.py b/tests/components/tts/test_media_source.py index 6065c97d6d65..86f1a3bcf3e2 100644 --- a/tests/components/tts/test_media_source.py +++ b/tests/components/tts/test_media_source.py @@ -52,7 +52,7 @@ async def test_browsing(hass: HomeAssistant, setup: str) -> None: item = await media_source.async_browse_media(hass, "media-source://tts") assert item is not None - assert item.title == "Text to Speech" + assert item.title == "Text-to-speech" assert item.children is not None assert len(item.children) == 1 assert item.can_play is False From 4bade86dccabdf1b758fa9f9f2ad853252946f3d Mon Sep 17 00:00:00 2001 From: Justin Vanderhooft Date: Wed, 31 May 2023 16:00:52 +0100 Subject: [PATCH 750/822] Add time component to Melnor Bluetooth integration (#93652) * Add time component to Melnor Bluetooth integration * Apply suggestions from code review --------- Co-authored-by: Paulus Schoutsen --- homeassistant/components/melnor/__init__.py | 1 + homeassistant/components/melnor/manifest.json | 2 +- homeassistant/components/melnor/time.py | 91 +++++++++++++++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/melnor/test_time.py | 50 ++++++++++ 6 files changed, 145 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/melnor/time.py create mode 100644 tests/components/melnor/test_time.py diff --git a/homeassistant/components/melnor/__init__.py b/homeassistant/components/melnor/__init__.py index 3cd9fee4fe7a..9a15e81dc22c 100644 --- a/homeassistant/components/melnor/__init__.py +++ b/homeassistant/components/melnor/__init__.py @@ -18,6 +18,7 @@ PLATFORMS: list[Platform] = [ Platform.NUMBER, Platform.SENSOR, Platform.SWITCH, + Platform.TIME, ] diff --git a/homeassistant/components/melnor/manifest.json b/homeassistant/components/melnor/manifest.json index 8d5d86a8bf71..87a5583fa4fd 100644 --- a/homeassistant/components/melnor/manifest.json +++ b/homeassistant/components/melnor/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/melnor", "iot_class": "local_polling", - "requirements": ["melnor-bluetooth==0.0.21"] + "requirements": ["melnor-bluetooth==0.0.22"] } diff --git a/homeassistant/components/melnor/time.py b/homeassistant/components/melnor/time.py new file mode 100644 index 000000000000..7abdf62e20c9 --- /dev/null +++ b/homeassistant/components/melnor/time.py @@ -0,0 +1,91 @@ +"""Number support for Melnor Bluetooth water timer.""" + +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from datetime import time +from typing import Any + +from melnor_bluetooth.device import Valve + +from homeassistant.components.time import TimeEntity, TimeEntityDescription +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 .models import ( + MelnorDataUpdateCoordinator, + MelnorZoneEntity, + get_entities_for_valves, +) + + +@dataclass +class MelnorZoneTimeEntityDescriptionMixin: + """Mixin for required keys.""" + + set_time_fn: Callable[[Valve, time], Coroutine[Any, Any, None]] + state_fn: Callable[[Valve], Any] + + +@dataclass +class MelnorZoneTimeEntityDescription( + TimeEntityDescription, MelnorZoneTimeEntityDescriptionMixin +): + """Describes Melnor number entity.""" + + +ZONE_ENTITY_DESCRIPTIONS: list[MelnorZoneTimeEntityDescription] = [ + MelnorZoneTimeEntityDescription( + entity_category=EntityCategory.CONFIG, + key="frequency_start_time", + name="Schedule Start Time", + set_time_fn=lambda valve, value: valve.set_frequency_start_time(value), + state_fn=lambda valve: valve.frequency.start_time, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the number platform.""" + + coordinator: MelnorDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + get_entities_for_valves( + coordinator, + ZONE_ENTITY_DESCRIPTIONS, + lambda valve, description: MelnorZoneTime(coordinator, description, valve), + ) + ) + + +class MelnorZoneTime(MelnorZoneEntity, TimeEntity): + """A time implementation for a melnor device.""" + + entity_description: MelnorZoneTimeEntityDescription + + def __init__( + self, + coordinator: MelnorDataUpdateCoordinator, + entity_description: MelnorZoneTimeEntityDescription, + valve: Valve, + ) -> None: + """Initialize a number for a melnor device.""" + super().__init__(coordinator, entity_description, valve) + + @property + def native_value(self) -> time | None: + """Return the current value.""" + return self.entity_description.state_fn(self._valve) + + async def async_set_value(self, value: time) -> None: + """Update the current value.""" + await self.entity_description.set_time_fn(self._valve, value) diff --git a/requirements_all.txt b/requirements_all.txt index 92449d083b7e..51293088a313 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1122,7 +1122,7 @@ mcstatus==6.0.0 meater-python==0.0.8 # homeassistant.components.melnor -melnor-bluetooth==0.0.21 +melnor-bluetooth==0.0.22 # homeassistant.components.message_bird messagebird==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dffbc65c8126..032884adccc6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -848,7 +848,7 @@ mcstatus==6.0.0 meater-python==0.0.8 # homeassistant.components.melnor -melnor-bluetooth==0.0.21 +melnor-bluetooth==0.0.22 # homeassistant.components.meteo_france meteofrance-api==1.2.0 diff --git a/tests/components/melnor/test_time.py b/tests/components/melnor/test_time.py new file mode 100644 index 000000000000..682f518d40be --- /dev/null +++ b/tests/components/melnor/test_time.py @@ -0,0 +1,50 @@ +"""Test the Melnor time platform.""" +from __future__ import annotations + +from datetime import time + +from homeassistant.core import HomeAssistant +import homeassistant.util.dt as dt_util + +from .conftest import ( + mock_config_entry, + patch_async_ble_device_from_address, + patch_async_register_callback, + patch_melnor_device, +) + +from tests.common import async_fire_time_changed + + +async def test_schedule_start_time(hass: HomeAssistant) -> None: + """Test the frequency schedule start time.""" + + now = dt_util.now() + + entry = mock_config_entry(hass) + + with patch_async_ble_device_from_address(), patch_melnor_device() as device_patch, patch_async_register_callback(): + device = device_patch.return_value + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + time_entity = hass.states.get("time.zone_1_schedule_start_time") + + assert time_entity is not None + assert time_entity.state == device.zone1.frequency.start_time.isoformat() + + await hass.services.async_call( + "time", + "set_value", + {"entity_id": "time.zone_1_schedule_start_time", "time": time(1, 0)}, + blocking=True, + ) + + async_fire_time_changed(hass, now + dt_util.dt.timedelta(seconds=10)) + await hass.async_block_till_done() + + time_entity = hass.states.get("time.zone_1_schedule_start_time") + + assert time_entity is not None + assert time_entity.state == time(1, 0).isoformat() From 927b59fe5a8ac7652f4fe20530a995e9b9f08fff Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 31 May 2023 11:06:03 -0400 Subject: [PATCH 751/822] Simplify get pipeline method (#93865) --- .../components/assist_pipeline/__init__.py | 13 +++++-------- .../components/assist_pipeline/pipeline.py | 15 +++++++++++---- .../components/assist_pipeline/websocket_api.py | 6 ++++-- .../components/esphome/voice_assistant.py | 10 ++++++++++ homeassistant/components/voip/voip.py | 9 ++++++++- 5 files changed, 38 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/assist_pipeline/__init__.py b/homeassistant/components/assist_pipeline/__init__.py index 055b4773be3a..9e460464cb9a 100644 --- a/homeassistant/components/assist_pipeline/__init__.py +++ b/homeassistant/components/assist_pipeline/__init__.py @@ -34,6 +34,7 @@ __all__ = ( "Pipeline", "PipelineEvent", "PipelineEventType", + "PipelineNotFound", ) CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) @@ -57,13 +58,10 @@ async def async_pipeline_from_audio_stream( conversation_id: str | None = None, tts_audio_output: str | None = None, ) -> None: - """Create an audio pipeline from an audio stream.""" - pipeline = async_get_pipeline(hass, pipeline_id=pipeline_id) - if pipeline is None: - raise PipelineNotFound( - "pipeline_not_found", f"Pipeline {pipeline_id} not found" - ) + """Create an audio pipeline from an audio stream. + Raises PipelineNotFound if no pipeline is found. + """ pipeline_input = PipelineInput( conversation_id=conversation_id, stt_metadata=stt_metadata, @@ -71,13 +69,12 @@ async def async_pipeline_from_audio_stream( run=PipelineRun( hass, context=context, - pipeline=pipeline, + pipeline=async_get_pipeline(hass, pipeline_id=pipeline_id), start_stage=PipelineStage.STT, end_stage=PipelineStage.TTS, event_callback=event_callback, tts_audio_output=tts_audio_output, ), ) - await pipeline_input.validate() await pipeline_input.execute() diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index c169ab657b86..12764c04f046 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -36,6 +36,7 @@ from .const import DOMAIN from .error import ( IntentRecognitionError, PipelineError, + PipelineNotFound, SpeechToTextError, TextToSpeechError, ) @@ -208,9 +209,7 @@ async def async_create_default_pipeline( @callback -def async_get_pipeline( - hass: HomeAssistant, pipeline_id: str | None = None -) -> Pipeline | None: +def async_get_pipeline(hass: HomeAssistant, pipeline_id: str | None = None) -> Pipeline: """Get a pipeline by id or the preferred pipeline.""" pipeline_data: PipelineData = hass.data[DOMAIN] @@ -218,7 +217,15 @@ def async_get_pipeline( # A pipeline was not specified, use the preferred one pipeline_id = pipeline_data.pipeline_store.async_get_preferred_item() - return pipeline_data.pipeline_store.data.get(pipeline_id) + pipeline = pipeline_data.pipeline_store.data.get(pipeline_id) + + # If invalid pipeline ID was specified + if pipeline is None: + raise PipelineNotFound( + "pipeline_not_found", f"Pipeline {pipeline_id} not found" + ) + + return pipeline @callback diff --git a/homeassistant/components/assist_pipeline/websocket_api.py b/homeassistant/components/assist_pipeline/websocket_api.py index a4afbe9266ab..3d8a07dc0b3b 100644 --- a/homeassistant/components/assist_pipeline/websocket_api.py +++ b/homeassistant/components/assist_pipeline/websocket_api.py @@ -17,6 +17,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.util import language as language_util from .const import DOMAIN +from .error import PipelineNotFound from .pipeline import ( PipelineData, PipelineError, @@ -85,8 +86,9 @@ async def websocket_run( ) -> None: """Run a pipeline.""" pipeline_id = msg.get("pipeline") - pipeline = async_get_pipeline(hass, pipeline_id=pipeline_id) - if pipeline is None: + try: + pipeline = async_get_pipeline(hass, pipeline_id=pipeline_id) + except PipelineNotFound: connection.send_error( msg["id"], "pipeline-not-found", diff --git a/homeassistant/components/esphome/voice_assistant.py b/homeassistant/components/esphome/voice_assistant.py index 5cd9c1d931f0..efb4162ae1a6 100644 --- a/homeassistant/components/esphome/voice_assistant.py +++ b/homeassistant/components/esphome/voice_assistant.py @@ -15,6 +15,7 @@ from homeassistant.components import stt, tts from homeassistant.components.assist_pipeline import ( PipelineEvent, PipelineEventType, + PipelineNotFound, async_pipeline_from_audio_stream, select as pipeline_select, ) @@ -337,6 +338,15 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): await self._tts_done.wait() _LOGGER.debug("Pipeline finished") + except PipelineNotFound: + self.handle_event( + VoiceAssistantEventType.VOICE_ASSISTANT_ERROR, + { + "code": "pipeline not found", + "message": "Selected pipeline timeout", + }, + ) + _LOGGER.warning("Pipeline not found") except asyncio.TimeoutError: self.handle_event( VoiceAssistantEventType.VOICE_ASSISTANT_ERROR, diff --git a/homeassistant/components/voip/voip.py b/homeassistant/components/voip/voip.py index 8ef03e0ddf0c..80e24d6eb838 100644 --- a/homeassistant/components/voip/voip.py +++ b/homeassistant/components/voip/voip.py @@ -18,6 +18,7 @@ from homeassistant.components.assist_pipeline import ( Pipeline, PipelineEvent, PipelineEventType, + PipelineNotFound, async_get_pipeline, async_pipeline_from_audio_stream, select as pipeline_select, @@ -45,7 +46,11 @@ def make_protocol( DOMAIN, voip_device.voip_id, ) - pipeline = async_get_pipeline(hass, pipeline_id) + try: + pipeline: Pipeline | None = async_get_pipeline(hass, pipeline_id) + except PipelineNotFound: + pipeline = None + if ( (pipeline is None) or (pipeline.stt_engine is None) @@ -261,6 +266,8 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): await self._tts_done.wait() _LOGGER.debug("Pipeline finished") + except PipelineNotFound: + _LOGGER.warning("Pipeline not found") except asyncio.TimeoutError: # Expected after caller hangs up _LOGGER.debug("Pipeline timeout") From bd8c88f51bcb09c488f872925a9c06cc31e4742a Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Wed, 31 May 2023 11:09:01 -0400 Subject: [PATCH 752/822] Add error handling for all zwave_js service calls (#93846) * Add error handling for all service calls * Switch siren to use internal function * Remove failing checks * Revert change to poll service, add comments, and add additional error handling * Add error handling for ping and refresh + review comment + add tests * Add test for statistics entity refresh --- homeassistant/components/zwave_js/button.py | 5 ++- homeassistant/components/zwave_js/climate.py | 12 +++---- homeassistant/components/zwave_js/cover.py | 24 ++++++------- homeassistant/components/zwave_js/entity.py | 16 ++++++--- homeassistant/components/zwave_js/fan.py | 18 +++++----- .../components/zwave_js/humidifier.py | 6 ++-- homeassistant/components/zwave_js/light.py | 6 ++-- homeassistant/components/zwave_js/lock.py | 18 ++++++++-- homeassistant/components/zwave_js/number.py | 2 +- homeassistant/components/zwave_js/select.py | 6 ++-- homeassistant/components/zwave_js/sensor.py | 22 +++++++++--- homeassistant/components/zwave_js/services.py | 7 ++-- homeassistant/components/zwave_js/siren.py | 16 +++------ homeassistant/components/zwave_js/update.py | 3 ++ tests/components/zwave_js/test_climate.py | 22 +++++++++++- tests/components/zwave_js/test_lock.py | 30 ++++++++++++++++ tests/components/zwave_js/test_sensor.py | 35 +++++++++++++++++-- tests/components/zwave_js/test_services.py | 12 +++++++ 18 files changed, 191 insertions(+), 69 deletions(-) diff --git a/homeassistant/components/zwave_js/button.py b/homeassistant/components/zwave_js/button.py index 7a6ec4c276c0..33d1e6dfa63d 100644 --- a/homeassistant/components/zwave_js/button.py +++ b/homeassistant/components/zwave_js/button.py @@ -77,7 +77,7 @@ class ZwaveBooleanNodeButton(ZWaveBaseEntity, ButtonEntity): async def async_press(self) -> None: """Press the button.""" - await self.info.node.async_set_value(self.info.primary_value, True) + await self._async_set_value(self.info.primary_value, True) class ZWaveNodePingButton(ButtonEntity): @@ -100,6 +100,9 @@ class ZWaveNodePingButton(ButtonEntity): async def async_poll_value(self, _: bool) -> None: """Poll a value.""" + # We log an error instead of raising an exception because this service call occurs + # in a separate task since it is called via the dispatcher and we don't want to + # raise the exception in that separate task because it is confusing to the user. LOGGER.error( "There is no value to refresh for this entity so the zwave_js.refresh_value" " service won't work for it" diff --git a/homeassistant/components/zwave_js/climate.py b/homeassistant/components/zwave_js/climate.py index 5397ab3a65b7..82c212a99a52 100644 --- a/homeassistant/components/zwave_js/climate.py +++ b/homeassistant/components/zwave_js/climate.py @@ -437,7 +437,7 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): except StopIteration: raise ValueError(f"Received an invalid fan mode: {fan_mode}") from None - await self.info.node.async_set_value(self._fan_mode, new_state) + await self._async_set_value(self._fan_mode, new_state) async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" @@ -451,7 +451,7 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): ) target_temp: float | None = kwargs.get(ATTR_TEMPERATURE) if target_temp is not None: - await self.info.node.async_set_value(setpoint, target_temp) + await self._async_set_value(setpoint, target_temp) elif len(self._current_mode_setpoint_enums) == 2: setpoint_low: ZwaveValue = self._setpoint_value_or_raise( self._current_mode_setpoint_enums[0] @@ -462,9 +462,9 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): target_temp_low: float | None = kwargs.get(ATTR_TARGET_TEMP_LOW) target_temp_high: float | None = kwargs.get(ATTR_TARGET_TEMP_HIGH) if target_temp_low is not None: - await self.info.node.async_set_value(setpoint_low, target_temp_low) + await self._async_set_value(setpoint_low, target_temp_low) if target_temp_high is not None: - await self.info.node.async_set_value(setpoint_high, target_temp_high) + await self._async_set_value(setpoint_high, target_temp_high) async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" @@ -475,7 +475,7 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): # Thermostat(valve) has no support for setting a mode, so we make it a no-op return - await self.info.node.async_set_value(self._current_mode, hvac_mode_id) + await self._async_set_value(self._current_mode, hvac_mode_id) async def async_set_preset_mode(self, preset_mode: str) -> None: """Set new target preset mode.""" @@ -487,7 +487,7 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): preset_mode_value = self._hvac_presets.get(preset_mode) if preset_mode_value is None: raise ValueError(f"Received an invalid preset mode: {preset_mode}") - await self.info.node.async_set_value(self._current_mode, preset_mode_value) + await self._async_set_value(self._current_mode, preset_mode_value) class DynamicCurrentTempClimate(ZWaveClimate): diff --git a/homeassistant/components/zwave_js/cover.py b/homeassistant/components/zwave_js/cover.py index 1521d6e567f6..4b3113af2021 100644 --- a/homeassistant/components/zwave_js/cover.py +++ b/homeassistant/components/zwave_js/cover.py @@ -163,7 +163,7 @@ class CoverPositionMixin(ZWaveBaseEntity, CoverEntity): async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" assert self._target_position_value - await self.info.node.async_set_value( + await self._async_set_value( self._target_position_value, self.percent_to_zwave_position(kwargs[ATTR_POSITION]), ) @@ -171,14 +171,14 @@ class CoverPositionMixin(ZWaveBaseEntity, CoverEntity): async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" assert self._target_position_value - await self.info.node.async_set_value( + await self._async_set_value( self._target_position_value, self._fully_open_position ) async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" assert self._target_position_value - await self.info.node.async_set_value( + await self._async_set_value( self._target_position_value, self._fully_closed_position ) @@ -186,7 +186,7 @@ class CoverPositionMixin(ZWaveBaseEntity, CoverEntity): """Stop cover.""" assert self._stop_position_value # Stop the cover, will stop regardless of the actual direction of travel. - await self.info.node.async_set_value(self._stop_position_value, False) + await self._async_set_value(self._stop_position_value, False) class CoverTiltMixin(ZWaveBaseEntity, CoverEntity): @@ -259,7 +259,7 @@ class CoverTiltMixin(ZWaveBaseEntity, CoverEntity): async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover tilt to a specific position.""" assert self._target_tilt_value - await self.info.node.async_set_value( + await self._async_set_value( self._target_tilt_value, self.percent_to_zwave_tilt(kwargs[ATTR_TILT_POSITION]), ) @@ -267,22 +267,18 @@ class CoverTiltMixin(ZWaveBaseEntity, CoverEntity): async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open the cover tilt.""" assert self._target_tilt_value - await self.info.node.async_set_value( - self._target_tilt_value, self._fully_open_tilt - ) + await self._async_set_value(self._target_tilt_value, self._fully_open_tilt) async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close the cover tilt.""" assert self._target_tilt_value - await self.info.node.async_set_value( - self._target_tilt_value, self._fully_closed_tilt - ) + await self._async_set_value(self._target_tilt_value, self._fully_closed_tilt) async def async_stop_cover_tilt(self, **kwargs: Any) -> None: """Stop the cover tilt.""" assert self._stop_tilt_value # Stop the tilt, will stop regardless of the actual direction of travel. - await self.info.node.async_set_value(self._stop_tilt_value, False) + await self._async_set_value(self._stop_tilt_value, False) class ZWaveMultilevelSwitchCover(CoverPositionMixin): @@ -455,8 +451,8 @@ class ZwaveMotorizedBarrier(ZWaveBaseEntity, CoverEntity): async def async_open_cover(self, **kwargs: Any) -> None: """Open the garage door.""" - await self.info.node.async_set_value(self._target_state, BarrierState.OPEN) + await self._async_set_value(self._target_state, BarrierState.OPEN) async def async_close_cover(self, **kwargs: Any) -> None: """Close the garage door.""" - await self.info.node.async_set_value(self._target_state, BarrierState.CLOSED) + await self._async_set_value(self._target_state, BarrierState.CLOSED) diff --git a/homeassistant/components/zwave_js/entity.py b/homeassistant/components/zwave_js/entity.py index ba2d753112cd..4d72ad858211 100644 --- a/homeassistant/components/zwave_js/entity.py +++ b/homeassistant/components/zwave_js/entity.py @@ -67,12 +67,20 @@ class ZWaveBaseEntity(Entity): To be overridden by platforms needing this event. """ + async def _async_poll_value(self, value_or_id: str | ZwaveValue) -> None: + """Poll a value.""" + # We log an error instead of raising an exception because this service call occurs + # in a separate task and we don't want to raise the exception in that separate task + # because it is confusing to the user. + try: + await self.info.node.async_poll_value(value_or_id) + except BaseZwaveJSServerError as err: + LOGGER.error("Error while refreshing value %s: %s", value_or_id, err) + async def async_poll_value(self, refresh_all_values: bool) -> None: """Poll a value.""" if not refresh_all_values: - self.hass.async_create_task( - self.info.node.async_poll_value(self.info.primary_value) - ) + self.hass.async_create_task(self._async_poll_value(self.info.primary_value)) LOGGER.info( ( "Refreshing primary value %s for %s, " @@ -84,7 +92,7 @@ class ZWaveBaseEntity(Entity): return for value_id in self.watched_value_ids: - self.hass.async_create_task(self.info.node.async_poll_value(value_id)) + self.hass.async_create_task(self._async_poll_value(value_id)) LOGGER.info( ( diff --git a/homeassistant/components/zwave_js/fan.py b/homeassistant/components/zwave_js/fan.py index ea00b70b9496..d06306497657 100644 --- a/homeassistant/components/zwave_js/fan.py +++ b/homeassistant/components/zwave_js/fan.py @@ -100,7 +100,7 @@ class ZwaveFan(ZWaveBaseEntity, FanEntity): percentage_to_ranged_value(DEFAULT_SPEED_RANGE, percentage) ) - await self.info.node.async_set_value(self._target_value, zwave_speed) + await self._async_set_value(self._target_value, zwave_speed) async def async_turn_on( self, @@ -122,15 +122,13 @@ class ZwaveFan(ZWaveBaseEntity, FanEntity): # when setting to a previous value to avoid waiting for the value to be # updated from the device which is typically delayed and causes a confusing # UX. - await self.info.node.async_set_value( - self._target_value, SET_TO_PREVIOUS_VALUE - ) + await self._async_set_value(self._target_value, SET_TO_PREVIOUS_VALUE) self._use_optimistic_state = True self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" - await self.info.node.async_set_value(self._target_value, 0) + await self._async_set_value(self._target_value, 0) @property def is_on(self) -> bool | None: @@ -174,13 +172,13 @@ class ValueMappingZwaveFan(ZwaveFan): async def async_set_percentage(self, percentage: int) -> None: """Set the speed percentage of the fan.""" zwave_speed = self.percentage_to_zwave_speed(percentage) - await self.info.node.async_set_value(self._target_value, zwave_speed) + await self._async_set_value(self._target_value, zwave_speed) async def async_set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" for zwave_value, mapped_preset_mode in self.fan_value_mapping.presets.items(): if preset_mode == mapped_preset_mode: - await self.info.node.async_set_value(self._target_value, zwave_value) + await self._async_set_value(self._target_value, zwave_value) return raise NotValidPresetModeError( @@ -342,13 +340,13 @@ class ZwaveThermostatFan(ZWaveBaseEntity, FanEntity): """Turn the device on.""" if not self._fan_off: raise HomeAssistantError("Unhandled action turn_on") - await self.info.node.async_set_value(self._fan_off, False) + await self._async_set_value(self._fan_off, False) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" if not self._fan_off: raise HomeAssistantError("Unhandled action turn_off") - await self.info.node.async_set_value(self._fan_off, True) + await self._async_set_value(self._fan_off, True) @property def is_on(self) -> bool | None: @@ -377,7 +375,7 @@ class ZwaveThermostatFan(ZWaveBaseEntity, FanEntity): except StopIteration: raise ValueError(f"Received an invalid fan mode: {preset_mode}") from None - await self.info.node.async_set_value(self._fan_mode, new_state) + await self._async_set_value(self._fan_mode, new_state) @property def preset_modes(self) -> list[str] | None: diff --git a/homeassistant/components/zwave_js/humidifier.py b/homeassistant/components/zwave_js/humidifier.py index 80760930c2e7..02c6abbc732a 100644 --- a/homeassistant/components/zwave_js/humidifier.py +++ b/homeassistant/components/zwave_js/humidifier.py @@ -175,7 +175,7 @@ class ZWaveHumidifier(ZWaveBaseEntity, HumidifierEntity): else: return - await self.info.node.async_set_value(self._current_mode, new_mode) + await self._async_set_value(self._current_mode, new_mode) async def async_turn_off(self, **kwargs: Any) -> None: """Turn off device.""" @@ -192,7 +192,7 @@ class ZWaveHumidifier(ZWaveBaseEntity, HumidifierEntity): else: return - await self.info.node.async_set_value(self._current_mode, new_mode) + await self._async_set_value(self._current_mode, new_mode) @property def target_humidity(self) -> int | None: @@ -204,7 +204,7 @@ class ZWaveHumidifier(ZWaveBaseEntity, HumidifierEntity): async def async_set_humidity(self, humidity: int) -> None: """Set new target humidity.""" if self._setpoint: - await self.info.node.async_set_value(self._setpoint, humidity) + await self._async_set_value(self._setpoint, humidity) @property def min_humidity(self) -> int: diff --git a/homeassistant/components/zwave_js/light.py b/homeassistant/components/zwave_js/light.py index e7ef38e5c18a..1a9abb9b0f83 100644 --- a/homeassistant/components/zwave_js/light.py +++ b/homeassistant/components/zwave_js/light.py @@ -324,9 +324,7 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): color_name = MULTI_COLOR_MAP[color] colors_dict[color_name] = value # set updated color object - await self.info.node.async_set_value( - combined_color_val, colors_dict, zwave_transition - ) + await self._async_set_value(combined_color_val, colors_dict, zwave_transition) async def _async_set_brightness( self, brightness: int | None, transition: float | None = None @@ -350,7 +348,7 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): zwave_transition = {TRANSITION_DURATION_OPTION: "default"} # setting a value requires setting targetValue - await self.info.node.async_set_value( + await self._async_set_value( self._target_brightness, zwave_brightness, zwave_transition ) # We do an optimistic state update when setting to a previous value diff --git a/homeassistant/components/zwave_js/lock.py b/homeassistant/components/zwave_js/lock.py index efeadb9b6b33..ff4cb84b47ce 100644 --- a/homeassistant/components/zwave_js/lock.py +++ b/homeassistant/components/zwave_js/lock.py @@ -13,12 +13,14 @@ from zwave_js_server.const.command_class.lock import ( LOCK_CMD_CLASS_TO_PROPERTY_MAP, DoorLockMode, ) +from zwave_js_server.exceptions import BaseZwaveJSServerError from zwave_js_server.util.lock import clear_usercode, set_usercode from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN, LockEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -114,7 +116,7 @@ class ZWaveLock(ZWaveBaseEntity, LockEntity): ] ) if target_value is not None: - await self.info.node.async_set_value( + await self._async_set_value( target_value, STATE_TO_ZWAVE_MAP[self.info.primary_value.command_class][target_state], ) @@ -129,10 +131,20 @@ class ZWaveLock(ZWaveBaseEntity, LockEntity): async def async_set_lock_usercode(self, code_slot: int, usercode: str) -> None: """Set the usercode to index X on the lock.""" - await set_usercode(self.info.node, code_slot, usercode) + try: + await set_usercode(self.info.node, code_slot, usercode) + except BaseZwaveJSServerError as err: + raise HomeAssistantError( + f"Unable to set lock usercode on code_slot {code_slot}: {err}" + ) from err LOGGER.debug("User code at slot %s set", code_slot) async def async_clear_lock_usercode(self, code_slot: int) -> None: """Clear the usercode at index X on the lock.""" - await clear_usercode(self.info.node, code_slot) + try: + await clear_usercode(self.info.node, code_slot) + except BaseZwaveJSServerError as err: + raise HomeAssistantError( + f"Unable to clear lock usercode on code_slot {code_slot}: {err}" + ) from err LOGGER.debug("User code at slot %s cleared", code_slot) diff --git a/homeassistant/components/zwave_js/number.py b/homeassistant/components/zwave_js/number.py index 4cffa7a85221..6aa4a57ea4c7 100644 --- a/homeassistant/components/zwave_js/number.py +++ b/homeassistant/components/zwave_js/number.py @@ -164,6 +164,6 @@ class ZwaveVolumeNumberEntity(ZWaveBaseEntity, NumberEntity): async def async_set_native_value(self, value: float) -> None: """Set new value.""" - await self.info.node.async_set_value( + await self._async_set_value( self.info.primary_value, round(value * self.correction_factor) ) diff --git a/homeassistant/components/zwave_js/select.py b/homeassistant/components/zwave_js/select.py index 49d4fec4a20a..3956004336a0 100644 --- a/homeassistant/components/zwave_js/select.py +++ b/homeassistant/components/zwave_js/select.py @@ -92,7 +92,7 @@ class ZwaveSelectEntity(ZWaveBaseEntity, SelectEntity): for key, val in self.info.primary_value.metadata.states.items() if val == option ) - await self.info.node.async_set_value(self.info.primary_value, int(key)) + await self._async_set_value(self.info.primary_value, int(key)) class ZWaveConfigParameterSelectEntity(ZwaveSelectEntity): @@ -162,7 +162,7 @@ class ZwaveDefaultToneSelectEntity(ZWaveBaseEntity, SelectEntity): for key, val in self._tones_value.metadata.states.items() if val == option ) - await self.info.node.async_set_value(self.info.primary_value, int(key)) + await self._async_set_value(self.info.primary_value, int(key)) class ZwaveMultilevelSwitchSelectEntity(ZWaveBaseEntity, SelectEntity): @@ -197,4 +197,4 @@ class ZwaveMultilevelSwitchSelectEntity(ZWaveBaseEntity, SelectEntity): """Change the selected option.""" assert self._target_value is not None key = next(key for key, val in self._lookup_map.items() if val == option) - await self.info.node.async_set_value(self._target_value, int(key)) + await self._async_set_value(self._target_value, int(key)) diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 3bb893c303ad..316c0b81eeb3 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -11,6 +11,7 @@ from zwave_js_server.const.command_class.meter import ( RESET_METER_OPTION_TARGET_VALUE, RESET_METER_OPTION_TYPE, ) +from zwave_js_server.exceptions import BaseZwaveJSServerError from zwave_js_server.model.controller import Controller from zwave_js_server.model.controller.statistics import ControllerStatisticsDataType from zwave_js_server.model.driver import Driver @@ -43,6 +44,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -671,9 +673,15 @@ class ZWaveMeterSensor(ZWaveNumericSensor): if value is not None: options[RESET_METER_OPTION_TARGET_VALUE] = value args = [options] if options else [] - await node.endpoints[endpoint].async_invoke_cc_api( - CommandClass.METER, "reset", *args, wait_for_result=False - ) + try: + await node.endpoints[endpoint].async_invoke_cc_api( + CommandClass.METER, "reset", *args, wait_for_result=False + ) + except BaseZwaveJSServerError as err: + LOGGER.error( + "Failed to reset meters on node %s endpoint %s: %s", node, endpoint, err + ) + raise HomeAssistantError from err LOGGER.debug( "Meters on node %s endpoint %s reset with the following options: %s", node, @@ -802,6 +810,9 @@ class ZWaveNodeStatusSensor(SensorEntity): async def async_poll_value(self, _: bool) -> None: """Poll a value.""" + # We log an error instead of raising an exception because this service call occurs + # in a separate task since it is called via the dispatcher and we don't want to + # raise the exception in that separate task because it is confusing to the user. LOGGER.error( "There is no value to refresh for this entity so the zwave_js.refresh_value" " service won't work for it" @@ -878,7 +889,10 @@ class ZWaveStatisticsSensor(SensorEntity): async def async_poll_value(self, _: bool) -> None: """Poll a value.""" - raise ValueError( + # We log an error instead of raising an exception because this service call occurs + # in a separate task since it is called via the dispatcher and we don't want to + # raise the exception in that separate task because it is confusing to the user. + LOGGER.error( "There is no value to refresh for this entity so the zwave_js.refresh_value" " service won't work for it" ) diff --git a/homeassistant/components/zwave_js/services.py b/homeassistant/components/zwave_js/services.py index 6f4f9f8e3301..133cb4074055 100644 --- a/homeassistant/components/zwave_js/services.py +++ b/homeassistant/components/zwave_js/services.py @@ -633,8 +633,11 @@ class ZWaveServices: "calls will still work for now but the service will be removed in a " "future release" ) - nodes: set[ZwaveNode] = service.data[const.ATTR_NODES] - await asyncio.gather(*(node.async_ping() for node in nodes)) + nodes: list[ZwaveNode] = list(service.data[const.ATTR_NODES]) + results = await asyncio.gather( + *(node.async_ping() for node in nodes), return_exceptions=True + ) + raise_exceptions_from_results(nodes, results) async def async_invoke_cc_api(self, service: ServiceCall) -> None: """Invoke a command class API.""" diff --git a/homeassistant/components/zwave_js/siren.py b/homeassistant/components/zwave_js/siren.py index 5a53d1155281..6de6b0f4e45b 100644 --- a/homeassistant/components/zwave_js/siren.py +++ b/homeassistant/components/zwave_js/siren.py @@ -79,14 +79,6 @@ class ZwaveSirenEntity(ZWaveBaseEntity, SirenEntity): return None return bool(self.info.primary_value.value) - async def async_set_value( - self, new_value: int, options: dict[str, Any] | None = None - ) -> None: - """Set a value on a siren node.""" - await self.info.node.async_set_value( - self.info.primary_value, new_value, options=options - ) - async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" tone_id: int | None = kwargs.get(ATTR_TONE) @@ -95,11 +87,13 @@ class ZwaveSirenEntity(ZWaveBaseEntity, SirenEntity): options["volume"] = round(volume * 100) # Play the default tone if a tone isn't provided if tone_id is None: - await self.async_set_value(ToneID.DEFAULT, options) + await self._async_set_value( + self.info.primary_value, ToneID.DEFAULT, options + ) return - await self.async_set_value(tone_id, options) + await self._async_set_value(self.info.primary_value, tone_id, options) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" - await self.async_set_value(ToneID.OFF) + await self._async_set_value(self.info.primary_value, ToneID.OFF) diff --git a/homeassistant/components/zwave_js/update.py b/homeassistant/components/zwave_js/update.py index 243642086d02..8403e28a68b5 100644 --- a/homeassistant/components/zwave_js/update.py +++ b/homeassistant/components/zwave_js/update.py @@ -291,6 +291,9 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity): async def async_poll_value(self, _: bool) -> None: """Poll a value.""" + # We log an error instead of raising an exception because this service call occurs + # in a separate task since it is called via the dispatcher and we don't want to + # raise the exception in that separate task because it is confusing to the user. LOGGER.error( "There is no value to refresh for this entity so the zwave_js.refresh_value" " service won't work for it" diff --git a/tests/components/zwave_js/test_climate.py b/tests/components/zwave_js/test_climate.py index f0c746f7f221..00006673785c 100644 --- a/tests/components/zwave_js/test_climate.py +++ b/tests/components/zwave_js/test_climate.py @@ -5,6 +5,7 @@ from zwave_js_server.const.command_class.thermostat import ( THERMOSTAT_OPERATING_STATE_PROPERTY, ) from zwave_js_server.event import Event +from zwave_js_server.exceptions import FailedZWaveCommand from zwave_js_server.model.node import Node from homeassistant.components.climate import ( @@ -30,6 +31,7 @@ from homeassistant.components.climate import ( HVACMode, ) from homeassistant.components.zwave_js.climate import ATTR_FAN_STATE +from homeassistant.components.zwave_js.const import DOMAIN, SERVICE_REFRESH_VALUE from homeassistant.components.zwave_js.helpers import ZwaveValueMatcher from homeassistant.const import ( ATTR_ENTITY_ID, @@ -49,7 +51,11 @@ from .common import ( async def test_thermostat_v2( - hass: HomeAssistant, client, climate_radio_thermostat_ct100_plus, integration + hass: HomeAssistant, + client, + climate_radio_thermostat_ct100_plus, + integration, + caplog: pytest.LogCaptureFixture, ) -> None: """Test a thermostat v2 command class entity.""" node = climate_radio_thermostat_ct100_plus @@ -280,6 +286,20 @@ async def test_thermostat_v2( blocking=True, ) + # Refresh value should log an error when there is an issue + client.async_send_command.reset_mock() + client.async_send_command.side_effect = FailedZWaveCommand("test", 1, "test") + await hass.services.async_call( + DOMAIN, + SERVICE_REFRESH_VALUE, + { + ATTR_ENTITY_ID: CLIMATE_RADIO_THERMOSTAT_ENTITY, + }, + blocking=True, + ) + + assert "Error while refreshing value" in caplog.text + async def test_thermostat_different_endpoints( hass: HomeAssistant, diff --git a/tests/components/zwave_js/test_lock.py b/tests/components/zwave_js/test_lock.py index 42aee148affa..5a5711d9dad2 100644 --- a/tests/components/zwave_js/test_lock.py +++ b/tests/components/zwave_js/test_lock.py @@ -1,4 +1,5 @@ """Test the Z-Wave JS lock platform.""" +import pytest from zwave_js_server.const import CommandClass from zwave_js_server.const.command_class.lock import ( ATTR_CODE_SLOT, @@ -6,6 +7,7 @@ from zwave_js_server.const.command_class.lock import ( CURRENT_MODE_PROPERTY, ) from zwave_js_server.event import Event +from zwave_js_server.exceptions import FailedZWaveCommand from zwave_js_server.model.node import Node, NodeStatus from homeassistant.components.lock import ( @@ -27,6 +29,7 @@ from homeassistant.const import ( STATE_UNLOCKED, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from .common import SCHLAGE_BE469_LOCK_ENTITY, replace_value_of_zwave_value @@ -153,6 +156,33 @@ async def test_door_lock( } assert args["value"] == 0 + client.async_send_command.reset_mock() + + client.async_send_command.side_effect = FailedZWaveCommand("test", 1, "test") + # Test set usercode service error handling + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + ZWAVE_JS_DOMAIN, + SERVICE_SET_LOCK_USERCODE, + { + ATTR_ENTITY_ID: SCHLAGE_BE469_LOCK_ENTITY, + ATTR_CODE_SLOT: 1, + ATTR_USERCODE: "1234", + }, + blocking=True, + ) + + # Test clear usercode service error handling + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + ZWAVE_JS_DOMAIN, + SERVICE_CLEAR_LOCK_USERCODE, + {ATTR_ENTITY_ID: SCHLAGE_BE469_LOCK_ENTITY, ATTR_CODE_SLOT: 1}, + blocking=True, + ) + + client.async_send_command.reset_mock() + event = Event( type="dead", data={ diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index bf6ff36d8f0c..766d5684af58 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -4,6 +4,7 @@ import copy import pytest from zwave_js_server.const.command_class.meter import MeterType from zwave_js_server.event import Event +from zwave_js_server.exceptions import FailedZWaveCommand from zwave_js_server.model.node import Node from homeassistant.components.sensor import ( @@ -39,6 +40,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from .common import ( @@ -420,6 +422,18 @@ async def test_reset_meter( client.async_send_command_no_wait.reset_mock() + client.async_send_command_no_wait.side_effect = FailedZWaveCommand( + "test", 1, "test" + ) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_RESET_METER, + {ATTR_ENTITY_ID: METER_ENERGY_SENSOR}, + blocking=True, + ) + async def test_meter_attributes( hass: HomeAssistant, @@ -609,7 +623,7 @@ NODE_STATISTICS_SUFFIXES_UNKNOWN = { async def test_statistics_sensors( - hass: HomeAssistant, zp3111, client, integration + hass: HomeAssistant, zp3111, client, integration, caplog: pytest.LogCaptureFixture ) -> None: """Test statistics sensors.""" ent_reg = er.async_get(hass) @@ -730,10 +744,27 @@ async def test_statistics_sensors( (NODE_STATISTICS_ENTITY_PREFIX, NODE_STATISTICS_SUFFIXES_UNKNOWN), ): for suffix_key, val in suffixes.items(): - state = hass.states.get(f"{prefix}{suffix_key}") + entity_id = f"{prefix}{suffix_key}" + state = hass.states.get(entity_id) assert state assert state.state == str(val) + await hass.services.async_call( + DOMAIN, + SERVICE_REFRESH_VALUE, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + assert caplog.text.count("There is no value to refresh for this entity") == len( + [ + *CONTROLLER_STATISTICS_SUFFIXES, + *CONTROLLER_STATISTICS_SUFFIXES_UNKNOWN, + *NODE_STATISTICS_SUFFIXES, + *NODE_STATISTICS_SUFFIXES_UNKNOWN, + ] + ) + ENERGY_PRODUCTION_ENTITY_MAP = { "energy_production_power": { diff --git a/tests/components/zwave_js/test_services.py b/tests/components/zwave_js/test_services.py index d1edfa5040f5..a92d34dd4127 100644 --- a/tests/components/zwave_js/test_services.py +++ b/tests/components/zwave_js/test_services.py @@ -1539,6 +1539,18 @@ async def test_ping( blocking=True, ) + client.async_send_command.reset_mock() + client.async_send_command.side_effect = FailedZWaveCommand("test", 1, "test") + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_PING, + { + ATTR_ENTITY_ID: CLIMATE_RADIO_THERMOSTAT_ENTITY, + }, + blocking=True, + ) + async def test_invoke_cc_api( hass: HomeAssistant, From f1552304e12735d0ff18f7489c2007908ad1e642 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Wed, 31 May 2023 11:15:44 -0400 Subject: [PATCH 753/822] Give zwave_js platinum quality score (#93845) --- homeassistant/components/zwave_js/manifest.json | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index f8be739f826f..b163ace1d24f 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -8,6 +8,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["zwave_js_server"], + "quality_scale": "platinum", "requirements": ["pyserial==3.5", "zwave-js-server-python==0.49.0"], "usb": [ { From 7b7ea24303d9f062ab514f951ff4cf7233945b7a Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Wed, 31 May 2023 17:20:53 +0200 Subject: [PATCH 754/822] Update knx-frontend to 2023.5.31.141540 (#93864) --- 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 63306ca5d19a..ba706c756cbd 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -13,6 +13,6 @@ "requirements": [ "xknx==2.10.0", "xknxproject==3.1.0", - "knx_frontend==2023.5.28.94855" + "knx_frontend==2023.5.31.141540" ] } diff --git a/requirements_all.txt b/requirements_all.txt index 51293088a313..23012a631ced 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1032,7 +1032,7 @@ kegtron-ble==0.4.0 kiwiki-client==0.1.1 # homeassistant.components.knx -knx_frontend==2023.5.28.94855 +knx_frontend==2023.5.31.141540 # homeassistant.components.konnected konnected==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 032884adccc6..9f0e817d227c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -791,7 +791,7 @@ justnimbus==0.6.0 kegtron-ble==0.4.0 # homeassistant.components.knx -knx_frontend==2023.5.28.94855 +knx_frontend==2023.5.31.141540 # homeassistant.components.konnected konnected==1.2.0 From 22e7c9cc7624ec39c72a872368aacdc687054e48 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 31 May 2023 17:23:13 +0200 Subject: [PATCH 755/822] Update frontend to 20230531.0 (#93862) --- 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 a7e110055704..12f98ef39f0e 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==20230526.0"] + "requirements": ["home-assistant-frontend==20230531.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 70dbe4199fdd..dd2f8b44fb46 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -25,7 +25,7 @@ ha-av==10.1.0 hass-nabucasa==0.67.1 hassil==1.0.6 home-assistant-bluetooth==1.10.0 -home-assistant-frontend==20230526.0 +home-assistant-frontend==20230531.0 home-assistant-intents==2023.5.30 httpx==0.24.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 23012a631ced..dd6e25400a3f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -924,7 +924,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230526.0 +home-assistant-frontend==20230531.0 # homeassistant.components.conversation home-assistant-intents==2023.5.30 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9f0e817d227c..3fe6051f6766 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -716,7 +716,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230526.0 +home-assistant-frontend==20230531.0 # homeassistant.components.conversation home-assistant-intents==2023.5.30 From cb616cdf4ea1387a3d012547415e26847a1b694e Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Wed, 31 May 2023 11:52:01 -0400 Subject: [PATCH 756/822] Move Z-Wave refresh value service out of task (#93866) --- homeassistant/components/zwave_js/entity.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zwave_js/entity.py b/homeassistant/components/zwave_js/entity.py index 4d72ad858211..ba086b3e8bfd 100644 --- a/homeassistant/components/zwave_js/entity.py +++ b/homeassistant/components/zwave_js/entity.py @@ -80,7 +80,7 @@ class ZWaveBaseEntity(Entity): async def async_poll_value(self, refresh_all_values: bool) -> None: """Poll a value.""" if not refresh_all_values: - self.hass.async_create_task(self._async_poll_value(self.info.primary_value)) + await self._async_poll_value(self.info.primary_value) LOGGER.info( ( "Refreshing primary value %s for %s, " @@ -92,7 +92,7 @@ class ZWaveBaseEntity(Entity): return for value_id in self.watched_value_ids: - self.hass.async_create_task(self._async_poll_value(value_id)) + await self._async_poll_value(value_id) LOGGER.info( ( From ab2e891e91cec4db721a336fb5ac42bdd616c2d5 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 31 May 2023 17:55:47 +0200 Subject: [PATCH 757/822] Bumped version to 2023.6.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 212000aa26d3..6c6b21ad0d4a 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -8,7 +8,7 @@ from .backports.enum import StrEnum APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 MINOR_VERSION: Final = 6 -PATCH_VERSION: Final = "0.dev0" +PATCH_VERSION: Final = "0b0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 10, 0) diff --git a/pyproject.toml b/pyproject.toml index 9bb55d3d29d5..2f1bec4171b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.6.0.dev0" +version = "2023.6.0b0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 408f0bdd6b4e0c697e66d0c9dcc6f60ba1fb4c0d Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Thu, 1 Jun 2023 02:03:55 +0100 Subject: [PATCH 758/822] Always update Filter sensors attr on new_state (#89096) * always update attr * reset filter on unit change --- homeassistant/components/filter/sensor.py | 27 +++++++++-------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/filter/sensor.py b/homeassistant/components/filter/sensor.py index 9b1e2250a28c..a733040da019 100644 --- a/homeassistant/components/filter/sensor.py +++ b/homeassistant/components/filter/sensor.py @@ -18,10 +18,8 @@ from homeassistant.components.input_number import DOMAIN as INPUT_NUMBER_DOMAIN from homeassistant.components.recorder import get_instance, history from homeassistant.components.sensor import ( ATTR_STATE_CLASS, - DEVICE_CLASSES as SENSOR_DEVICE_CLASSES, DOMAIN as SENSOR_DOMAIN, PLATFORM_SCHEMA, - STATE_CLASSES as SENSOR_STATE_CLASSES, SensorDeviceClass, SensorEntity, ) @@ -273,22 +271,15 @@ class SensorFilter(SensorEntity): self._state = temp_state.state - if self._attr_icon is None: - self._attr_icon = new_state.attributes.get(ATTR_ICON, ICON) + self._attr_icon = new_state.attributes.get(ATTR_ICON, ICON) + 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_device_class is None - and new_state.attributes.get(ATTR_DEVICE_CLASS) in SENSOR_DEVICE_CLASSES + if self._attr_native_unit_of_measurement != new_state.attributes.get( + ATTR_UNIT_OF_MEASUREMENT ): - self._attr_device_class = new_state.attributes.get(ATTR_DEVICE_CLASS) - - if ( - self._attr_state_class is None - and new_state.attributes.get(ATTR_STATE_CLASS) in SENSOR_STATE_CLASSES - ): - self._attr_state_class = new_state.attributes.get(ATTR_STATE_CLASS) - - if self._attr_native_unit_of_measurement is None: + for filt in self._filters: + filt.reset() self._attr_native_unit_of_measurement = new_state.attributes.get( ATTR_UNIT_OF_MEASUREMENT ) @@ -460,6 +451,10 @@ class Filter: """Return whether the current filter_state should be skipped.""" return self._skip_processing + def reset(self) -> None: + """Reset filter.""" + self.states.clear() + def _filter_state(self, new_state: FilterState) -> FilterState: """Implement filter.""" raise NotImplementedError() From 22ed622152f8a6556eec69c8950b82e04d2f3454 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Thu, 1 Jun 2023 02:10:15 +0100 Subject: [PATCH 759/822] Delay filter integration until after HA has started (#91034) * delay filter start * Update homeassistant/components/filter/sensor.py * Update homeassistant/components/filter/sensor.py --------- Co-authored-by: Paulus Schoutsen --- homeassistant/components/filter/sensor.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/filter/sensor.py b/homeassistant/components/filter/sensor.py index a733040da019..a1470baa4d2f 100644 --- a/homeassistant/components/filter/sensor.py +++ b/homeassistant/components/filter/sensor.py @@ -39,6 +39,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.reload import async_setup_reload_service +from homeassistant.helpers.start import async_at_started from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType from homeassistant.util.decorator import Registry import homeassistant.util.dt as dt_util @@ -351,11 +352,16 @@ class SensorFilter(SensorEntity): if state.state not in [STATE_UNKNOWN, STATE_UNAVAILABLE, None]: self._update_filter_sensor_state(state, False) - self.async_on_remove( - async_track_state_change_event( - self.hass, [self._entity], self._update_filter_sensor_state_event + @callback + def _async_hass_started(hass: HomeAssistant) -> None: + """Delay source entity tracking.""" + self.async_on_remove( + async_track_state_change_event( + self.hass, [self._entity], self._update_filter_sensor_state_event + ) ) - ) + + self.async_on_remove(async_at_started(self.hass, _async_hass_started)) @property def native_value(self) -> datetime | StateType: From d10dd54d883820697dd10c5f2a77e0e103dad89a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Thu, 1 Jun 2023 02:09:23 +0200 Subject: [PATCH 760/822] Update aioairzone-cloud to v0.1.7 (#93871) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update aioairzone-cloud to v0.1.7 Signed-off-by: Álvaro Fernández Rojas * airzone_cloud: fix copy&paste description Signed-off-by: Álvaro Fernández Rojas --------- Signed-off-by: Álvaro Fernández Rojas --- homeassistant/components/airzone_cloud/entity.py | 6 ++++++ homeassistant/components/airzone_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/airzone_cloud/util.py | 5 +++++ 5 files changed, 14 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airzone_cloud/entity.py b/homeassistant/components/airzone_cloud/entity.py index 59f6aa14bf8c..c7e59ee1a3f6 100644 --- a/homeassistant/components/airzone_cloud/entity.py +++ b/homeassistant/components/airzone_cloud/entity.py @@ -6,6 +6,7 @@ from typing import Any from aioairzone_cloud.const import ( AZD_AIDOOS, + AZD_AVAILABLE, AZD_FIRMWARE, AZD_NAME, AZD_SYSTEM_ID, @@ -26,6 +27,11 @@ from .coordinator import AirzoneUpdateCoordinator class AirzoneEntity(CoordinatorEntity[AirzoneUpdateCoordinator], ABC): """Define an Airzone Cloud entity.""" + @property + def available(self) -> bool: + """Return Airzone Cloud entity availability.""" + return super().available and self.get_airzone_value(AZD_AVAILABLE) + @abstractmethod def get_airzone_value(self, key: str) -> Any: """Return Airzone Cloud entity value by key.""" diff --git a/homeassistant/components/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json index d03fe5913c23..b2899a7c80ce 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.1.6"] + "requirements": ["aioairzone-cloud==0.1.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index dd6e25400a3f..f95653df2157 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -116,7 +116,7 @@ aio_georss_gdacs==0.8 aioairq==0.2.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.1.6 +aioairzone-cloud==0.1.7 # homeassistant.components.airzone aioairzone==0.6.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3fe6051f6766..b16d42bedf3b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -106,7 +106,7 @@ aio_georss_gdacs==0.8 aioairq==0.2.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.1.6 +aioairzone-cloud==0.1.7 # homeassistant.components.airzone aioairzone==0.6.1 diff --git a/tests/components/airzone_cloud/util.py b/tests/components/airzone_cloud/util.py index 044cf880a16a..4eab870297b5 100644 --- a/tests/components/airzone_cloud/util.py +++ b/tests/components/airzone_cloud/util.py @@ -32,6 +32,7 @@ from aioairzone_cloud.const import ( API_SYSTEM_NUMBER, API_TYPE, API_WARNINGS, + API_WS_CONNECTED, API_WS_FW, API_WS_ID, API_WS_IDS, @@ -160,6 +161,7 @@ def mock_get_device_status(device: Device) -> dict[str, Any]: return { API_ERRORS: [], API_IS_CONNECTED: True, + API_WS_CONNECTED: True, API_LOCAL_TEMP: { API_CELSIUS: 21, API_FAH: 70, @@ -170,12 +172,14 @@ def mock_get_device_status(device: Device) -> dict[str, Any]: return { API_ERRORS: [], API_IS_CONNECTED: True, + API_WS_CONNECTED: True, API_WARNINGS: [], } if device.get_id() == "zone2": return { API_HUMIDITY: 24, API_IS_CONNECTED: True, + API_WS_CONNECTED: True, API_LOCAL_TEMP: { API_FAH: 77, API_CELSIUS: 25, @@ -185,6 +189,7 @@ def mock_get_device_status(device: Device) -> dict[str, Any]: return { API_HUMIDITY: 30, API_IS_CONNECTED: True, + API_WS_CONNECTED: True, API_LOCAL_TEMP: { API_FAH: 68, API_CELSIUS: 20, From d6f2e1cdffa0b36afd55ff9e124891ed030dc1f5 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 31 May 2023 20:08:18 -0400 Subject: [PATCH 761/822] Increase Zigbee command retries (#93877) * Enable retries for requests * Update unit tests * Account for fewer group retries in unit tests --- .../zha/core/cluster_handlers/__init__.py | 4 + .../zha/test_alarm_control_panel.py | 5 + tests/components/zha/test_device_action.py | 4 +- tests/components/zha/test_discover.py | 2 +- tests/components/zha/test_light.py | 98 +++++++++++-------- tests/components/zha/test_switch.py | 4 +- 6 files changed, 71 insertions(+), 46 deletions(-) diff --git a/homeassistant/components/zha/core/cluster_handlers/__init__.py b/homeassistant/components/zha/core/cluster_handlers/__init__.py index 7863b0434555..ec29e4e53eb4 100644 --- a/homeassistant/components/zha/core/cluster_handlers/__init__.py +++ b/homeassistant/components/zha/core/cluster_handlers/__init__.py @@ -45,6 +45,8 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) +DEFAULT_REQUEST_RETRIES = 3 + class AttrReportConfig(TypedDict, total=True): """Configuration to report for the attributes.""" @@ -78,6 +80,8 @@ def decorate_command(cluster_handler, command): @wraps(command) async def wrapper(*args, **kwds): + kwds.setdefault("tries", DEFAULT_REQUEST_RETRIES) + try: result = await command(*args, **kwds) cluster_handler.debug( diff --git a/tests/components/zha/test_alarm_control_panel.py b/tests/components/zha/test_alarm_control_panel.py index 319301cf7dc3..34ce746e1287 100644 --- a/tests/components/zha/test_alarm_control_panel.py +++ b/tests/components/zha/test_alarm_control_panel.py @@ -96,6 +96,7 @@ async def test_alarm_control_panel( 0, security.IasAce.AudibleNotification.Default_Sound, security.IasAce.AlarmStatus.No_Alarm, + tries=3, ) # disarm from HA @@ -134,6 +135,7 @@ async def test_alarm_control_panel( 0, security.IasAce.AudibleNotification.Default_Sound, security.IasAce.AlarmStatus.Emergency, + tries=3, ) # reset the panel @@ -157,6 +159,7 @@ async def test_alarm_control_panel( 0, security.IasAce.AudibleNotification.Default_Sound, security.IasAce.AlarmStatus.No_Alarm, + tries=3, ) # arm_night from HA @@ -177,6 +180,7 @@ async def test_alarm_control_panel( 0, security.IasAce.AudibleNotification.Default_Sound, security.IasAce.AlarmStatus.No_Alarm, + tries=3, ) # reset the panel @@ -274,5 +278,6 @@ async def reset_alarm_panel(hass, cluster, entity_id): 0, security.IasAce.AudibleNotification.Default_Sound, security.IasAce.AlarmStatus.No_Alarm, + tries=3, ) cluster.client_command.reset_mock() diff --git a/tests/components/zha/test_device_action.py b/tests/components/zha/test_device_action.py index f1ab44f69eb2..9d9a4bc2a54e 100644 --- a/tests/components/zha/test_device_action.py +++ b/tests/components/zha/test_device_action.py @@ -328,7 +328,7 @@ async def test_action(hass: HomeAssistant, device_ias, device_inovelli) -> None: 5, expect_reply=False, manufacturer=4151, - tries=1, + tries=3, tsn=None, ) in cluster.request.call_args_list @@ -345,7 +345,7 @@ async def test_action(hass: HomeAssistant, device_ias, device_inovelli) -> None: 5, expect_reply=False, manufacturer=4151, - tries=1, + tries=3, tsn=None, ) in cluster.request.call_args_list diff --git a/tests/components/zha/test_discover.py b/tests/components/zha/test_discover.py index 236a3c4ad86a..a87d624ec00f 100644 --- a/tests/components/zha/test_discover.py +++ b/tests/components/zha/test_discover.py @@ -131,7 +131,7 @@ async def test_devices( ), expect_reply=True, manufacturer=None, - tries=1, + tries=3, tsn=None, ) ] diff --git a/tests/components/zha/test_light.py b/tests/components/zha/test_light.py index c4751f7e7f6e..5ea71573a27c 100644 --- a/tests/components/zha/test_light.py +++ b/tests/components/zha/test_light.py @@ -553,7 +553,7 @@ async def test_transitions( transition_time=0, expect_reply=True, manufacturer=None, - tries=1, + tries=3, tsn=None, ) @@ -589,7 +589,7 @@ async def test_transitions( transition_time=35, expect_reply=True, manufacturer=None, - tries=1, + tries=3, tsn=None, ) assert dev1_cluster_color.request.call_args == call( @@ -600,7 +600,7 @@ async def test_transitions( transition_time=35, expect_reply=True, manufacturer=None, - tries=1, + tries=3, tsn=None, ) @@ -637,7 +637,7 @@ async def test_transitions( transition_time=0, expect_reply=True, manufacturer=None, - tries=1, + tries=3, tsn=None, ) @@ -674,7 +674,7 @@ async def test_transitions( transition_time=0, expect_reply=True, manufacturer=None, - tries=1, + tries=3, tsn=None, ) assert dev1_cluster_color.request.call_args == call( @@ -685,7 +685,7 @@ async def test_transitions( transition_time=0, # no transition when new_color_provided_while_off expect_reply=True, manufacturer=None, - tries=1, + tries=3, tsn=None, ) assert dev1_cluster_level.request.call_args_list[1] == call( @@ -696,7 +696,7 @@ async def test_transitions( transition_time=10, expect_reply=True, manufacturer=None, - tries=1, + tries=3, tsn=None, ) @@ -758,7 +758,7 @@ async def test_transitions( transition_time=0, expect_reply=True, manufacturer=None, - tries=1, + tries=3, tsn=None, ) assert dev1_cluster_color.request.call_args == call( @@ -769,7 +769,7 @@ async def test_transitions( transition_time=0, # no transition when new_color_provided_while_off expect_reply=True, manufacturer=None, - tries=1, + tries=3, tsn=None, ) assert dev1_cluster_level.request.call_args_list[1] == call( @@ -780,7 +780,7 @@ async def test_transitions( transition_time=0, expect_reply=True, manufacturer=None, - tries=1, + tries=3, tsn=None, ) @@ -838,7 +838,7 @@ async def test_transitions( dev1_cluster_on_off.commands_by_name["on"].schema, expect_reply=True, manufacturer=None, - tries=1, + tries=3, tsn=None, ) @@ -850,7 +850,7 @@ async def test_transitions( transition_time=0, # no transition when new_color_provided_while_off expect_reply=True, manufacturer=None, - tries=1, + tries=3, tsn=None, ) @@ -910,7 +910,7 @@ async def test_transitions( transition_time=1, # transition time - sengled light uses default minimum expect_reply=True, manufacturer=None, - tries=1, + tries=3, tsn=None, ) @@ -968,7 +968,7 @@ async def test_transitions( transition_time=1, expect_reply=True, manufacturer=None, - tries=1, + tries=3, tsn=None, ) assert dev2_cluster_color.request.call_args == call( @@ -979,7 +979,7 @@ async def test_transitions( transition_time=1, # sengled transition == 1 when new_color_provided_while_off expect_reply=True, manufacturer=None, - tries=1, + tries=3, tsn=None, ) assert dev2_cluster_level.request.call_args_list[1] == call( @@ -990,7 +990,7 @@ async def test_transitions( transition_time=10, expect_reply=True, manufacturer=None, - tries=1, + tries=3, tsn=None, ) @@ -1121,7 +1121,7 @@ async def test_transitions( transition_time=20, # transition time expect_reply=True, manufacturer=None, - tries=1, + tries=3, tsn=None, ) @@ -1151,7 +1151,7 @@ async def test_transitions( transition_time=1, # transition time - sengled light uses default minimum expect_reply=True, manufacturer=None, - tries=1, + tries=3, tsn=None, ) @@ -1184,7 +1184,7 @@ async def test_transitions( eWeLink_cluster_on_off.commands_by_name["on"].schema, expect_reply=True, manufacturer=None, - tries=1, + tries=3, tsn=None, ) assert dev1_cluster_color.request.call_args == call( @@ -1195,7 +1195,7 @@ async def test_transitions( transition_time=0, expect_reply=True, manufacturer=None, - tries=1, + tries=3, tsn=None, ) @@ -1261,7 +1261,7 @@ async def test_on_with_off_color(hass: HomeAssistant, device_light_1) -> None: dev1_cluster_on_off.commands_by_name["on"].schema, expect_reply=True, manufacturer=None, - tries=1, + tries=3, tsn=None, ) assert dev1_cluster_color.request.call_args == call( @@ -1272,7 +1272,7 @@ async def test_on_with_off_color(hass: HomeAssistant, device_light_1) -> None: transition_time=0, expect_reply=True, manufacturer=None, - tries=1, + tries=3, tsn=None, ) @@ -1319,7 +1319,7 @@ async def test_on_with_off_color(hass: HomeAssistant, device_light_1) -> None: transition_time=0, expect_reply=True, manufacturer=None, - tries=1, + tries=3, tsn=None, ) assert dev1_cluster_color.request.call_args == call( @@ -1330,7 +1330,7 @@ async def test_on_with_off_color(hass: HomeAssistant, device_light_1) -> None: transition_time=0, expect_reply=True, manufacturer=None, - tries=1, + tries=3, tsn=None, ) assert dev1_cluster_level.request.call_args_list[1] == call( @@ -1341,7 +1341,7 @@ async def test_on_with_off_color(hass: HomeAssistant, device_light_1) -> None: transition_time=0, expect_reply=True, manufacturer=None, - tries=1, + tries=3, tsn=None, ) @@ -1373,7 +1373,9 @@ async def async_test_on_from_light(hass, cluster, entity_id): assert hass.states.get(entity_id).state == STATE_ON -async def async_test_on_off_from_hass(hass, cluster, entity_id): +async def async_test_on_off_from_hass( + hass, cluster, entity_id, expected_tries: int = 3 +): """Test on off functionality from hass.""" # turn on via UI cluster.request.reset_mock() @@ -1388,14 +1390,16 @@ async def async_test_on_off_from_hass(hass, cluster, entity_id): cluster.commands_by_name["on"].schema, expect_reply=True, manufacturer=None, - tries=1, + tries=expected_tries, tsn=None, ) - await async_test_off_from_hass(hass, cluster, entity_id) + await async_test_off_from_hass( + hass, cluster, entity_id, expected_tries=expected_tries + ) -async def async_test_off_from_hass(hass, cluster, entity_id): +async def async_test_off_from_hass(hass, cluster, entity_id, expected_tries: int = 3): """Test turning off the light from Home Assistant.""" # turn off via UI @@ -1411,13 +1415,18 @@ async def async_test_off_from_hass(hass, cluster, entity_id): cluster.commands_by_name["off"].schema, expect_reply=True, manufacturer=None, - tries=1, + tries=expected_tries, tsn=None, ) async def async_test_level_on_off_from_hass( - hass, on_off_cluster, level_cluster, entity_id, expected_default_transition: int = 0 + hass, + on_off_cluster, + level_cluster, + entity_id, + expected_default_transition: int = 0, + expected_tries: int = 3, ): """Test on off functionality from hass.""" @@ -1439,7 +1448,7 @@ async def async_test_level_on_off_from_hass( on_off_cluster.commands_by_name["on"].schema, expect_reply=True, manufacturer=None, - tries=1, + tries=expected_tries, tsn=None, ) on_off_cluster.request.reset_mock() @@ -1463,7 +1472,7 @@ async def async_test_level_on_off_from_hass( on_off_cluster.commands_by_name["on"].schema, expect_reply=True, manufacturer=None, - tries=1, + tries=expected_tries, tsn=None, ) assert level_cluster.request.call_args == call( @@ -1474,7 +1483,7 @@ async def async_test_level_on_off_from_hass( transition_time=100, expect_reply=True, manufacturer=None, - tries=1, + tries=expected_tries, tsn=None, ) on_off_cluster.request.reset_mock() @@ -1499,13 +1508,15 @@ async def async_test_level_on_off_from_hass( transition_time=int(expected_default_transition), expect_reply=True, manufacturer=None, - tries=1, + tries=expected_tries, tsn=None, ) on_off_cluster.request.reset_mock() level_cluster.request.reset_mock() - await async_test_off_from_hass(hass, on_off_cluster, entity_id) + await async_test_off_from_hass( + hass, on_off_cluster, entity_id, expected_tries=expected_tries + ) async def async_test_dimmer_from_light(hass, cluster, entity_id, level, expected_state): @@ -1522,7 +1533,9 @@ async def async_test_dimmer_from_light(hass, cluster, entity_id, level, expected assert hass.states.get(entity_id).attributes.get("brightness") == level -async def async_test_flash_from_hass(hass, cluster, entity_id, flash): +async def async_test_flash_from_hass( + hass, cluster, entity_id, flash, expected_tries: int = 3 +): """Test flash functionality from hass.""" # turn on via UI cluster.request.reset_mock() @@ -1542,7 +1555,7 @@ async def async_test_flash_from_hass(hass, cluster, entity_id, flash): effect_variant=general.Identify.EffectVariant.Default, expect_reply=True, manufacturer=None, - tries=1, + tries=expected_tries, tsn=None, ) @@ -1642,13 +1655,15 @@ async def test_zha_group_light_entity( assert "color_mode" not in group_state.attributes # test turning the lights on and off from the HA - await async_test_on_off_from_hass(hass, group_cluster_on_off, group_entity_id) + await async_test_on_off_from_hass( + hass, group_cluster_on_off, group_entity_id, expected_tries=1 + ) await async_shift_time(hass) # test short flashing the lights from the HA await async_test_flash_from_hass( - hass, group_cluster_identify, group_entity_id, FLASH_SHORT + hass, group_cluster_identify, group_entity_id, FLASH_SHORT, expected_tries=1 ) await async_shift_time(hass) @@ -1663,6 +1678,7 @@ async def test_zha_group_light_entity( group_cluster_level, group_entity_id, expected_default_transition=1, # a Sengled light is in that group and needs a minimum 0.1s transition + expected_tries=1, ) await async_shift_time(hass) @@ -1683,7 +1699,7 @@ async def test_zha_group_light_entity( # test long flashing the lights from the HA await async_test_flash_from_hass( - hass, group_cluster_identify, group_entity_id, FLASH_LONG + hass, group_cluster_identify, group_entity_id, FLASH_LONG, expected_tries=1 ) await async_shift_time(hass) diff --git a/tests/components/zha/test_switch.py b/tests/components/zha/test_switch.py index 9f98acb93597..8fb7825a9537 100644 --- a/tests/components/zha/test_switch.py +++ b/tests/components/zha/test_switch.py @@ -176,7 +176,7 @@ async def test_switch( cluster.commands_by_name["on"].schema, expect_reply=True, manufacturer=None, - tries=1, + tries=3, tsn=None, ) @@ -196,7 +196,7 @@ async def test_switch( cluster.commands_by_name["off"].schema, expect_reply=True, manufacturer=None, - tries=1, + tries=3, tsn=None, ) From fb50f0d875a6993adefe4fecfffd27dee49d9279 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 31 May 2023 21:14:59 -0400 Subject: [PATCH 762/822] Bump frontend to 20230601.0 (#93884) --- 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 12f98ef39f0e..bde1977b1c1d 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==20230531.0"] + "requirements": ["home-assistant-frontend==20230601.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index dd2f8b44fb46..c8c085b761c8 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -25,7 +25,7 @@ ha-av==10.1.0 hass-nabucasa==0.67.1 hassil==1.0.6 home-assistant-bluetooth==1.10.0 -home-assistant-frontend==20230531.0 +home-assistant-frontend==20230601.0 home-assistant-intents==2023.5.30 httpx==0.24.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index f95653df2157..89bf881e716f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -924,7 +924,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230531.0 +home-assistant-frontend==20230601.0 # homeassistant.components.conversation home-assistant-intents==2023.5.30 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b16d42bedf3b..0ca7cb47c34b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -716,7 +716,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230531.0 +home-assistant-frontend==20230601.0 # homeassistant.components.conversation home-assistant-intents==2023.5.30 From 65a9bd661d44103df78c63975de44369ad191678 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 31 May 2023 21:17:59 -0400 Subject: [PATCH 763/822] Bumped version to 2023.6.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 6c6b21ad0d4a..37eb0d02c606 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -8,7 +8,7 @@ from .backports.enum import StrEnum APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 MINOR_VERSION: Final = 6 -PATCH_VERSION: Final = "0b0" +PATCH_VERSION: Final = "0b1" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 10, 0) diff --git a/pyproject.toml b/pyproject.toml index 2f1bec4171b1..9d075644101d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.6.0b0" +version = "2023.6.0b1" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 32571af131e8609cf3e92ed396448de3fe6a1d9e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 1 Jun 2023 12:32:14 +0200 Subject: [PATCH 764/822] Add `silabs_multiprotocol` platform (#92904) * Add silabs_multiprotocol platform * Add new files * Add ZHA tests * Prevent ZHA from creating database during tests * Add delay parameter to async_change_channel * Add the updated dataset to the dataset store * Allow MultipanProtocol.async_change_channel to return a task * Notify user about the duration of migration * Update tests --- .../silabs_multiprotocol_addon.py | 261 ++++++++++++++++-- .../homeassistant_hardware/strings.json | 19 ++ .../homeassistant_sky_connect/strings.json | 19 ++ .../homeassistant_yellow/strings.json | 19 ++ .../components/otbr/silabs_multiprotocol.py | 87 ++++++ homeassistant/components/otbr/util.py | 50 ++-- homeassistant/components/zha/config_flow.py | 2 +- .../components/zha/silabs_multiprotocol.py | 81 ++++++ .../homeassistant_hardware/conftest.py | 13 +- .../test_silabs_multiprotocol_addon.py | 209 +++++++++++++- .../homeassistant_sky_connect/conftest.py | 13 +- .../homeassistant_yellow/conftest.py | 13 +- tests/components/otbr/conftest.py | 12 +- tests/components/otbr/test_config_flow.py | 17 +- tests/components/otbr/test_init.py | 30 +- .../otbr/test_silabs_multiprotocol.py | 175 ++++++++++++ tests/components/otbr/test_util.py | 55 +--- tests/components/otbr/test_websocket_api.py | 27 +- .../zha/test_silabs_multiprotocol.py | 118 ++++++++ 19 files changed, 1072 insertions(+), 148 deletions(-) create mode 100644 homeassistant/components/otbr/silabs_multiprotocol.py create mode 100644 homeassistant/components/zha/silabs_multiprotocol.py create mode 100644 tests/components/otbr/test_silabs_multiprotocol.py create mode 100644 tests/components/zha/test_silabs_multiprotocol.py diff --git a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py index 8c502f080f6c..34ab9a3cedbf 100644 --- a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py +++ b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py @@ -5,7 +5,7 @@ from abc import ABC, abstractmethod import asyncio import dataclasses import logging -from typing import Any +from typing import Any, Protocol import voluptuous as vol import yarl @@ -19,12 +19,19 @@ from homeassistant.components.hassio import ( hostname_from_addon_slug, is_hassio, ) -from homeassistant.components.zha import DOMAIN as ZHA_DOMAIN -from homeassistant.components.zha.radio_manager import ZhaMultiPANMigrationHelper from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.integration_platform import ( + async_process_integration_platforms, +) +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) from homeassistant.helpers.singleton import singleton +from homeassistant.helpers.storage import Store from .const import LOGGER, SILABS_MULTIPROTOCOL_ADDON_SLUG @@ -39,17 +46,144 @@ CONF_ADDON_AUTOFLASH_FW = "autoflash_firmware" CONF_ADDON_DEVICE = "device" CONF_ENABLE_MULTI_PAN = "enable_multi_pan" +DEFAULT_CHANNEL = 15 +DEFAULT_CHANNEL_CHANGE_DELAY = 5 * 60 # Thread recommendation + +STORAGE_KEY = "homeassistant_hardware.silabs" +STORAGE_VERSION_MAJOR = 1 +STORAGE_VERSION_MINOR = 1 +SAVE_DELAY = 10 + @singleton(DATA_ADDON_MANAGER) -@callback -def get_addon_manager(hass: HomeAssistant) -> AddonManager: +async def get_addon_manager(hass: HomeAssistant) -> MultiprotocolAddonManager: """Get the add-on manager.""" - return AddonManager( - hass, - LOGGER, - "Silicon Labs Multiprotocol", - SILABS_MULTIPROTOCOL_ADDON_SLUG, - ) + manager = MultiprotocolAddonManager(hass) + await manager.async_setup() + return manager + + +class MultiprotocolAddonManager(AddonManager): + """Silicon Labs Multiprotocol add-on manager.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the manager.""" + super().__init__( + hass, + LOGGER, + "Silicon Labs Multiprotocol", + SILABS_MULTIPROTOCOL_ADDON_SLUG, + ) + self._channel: int | None = None + self._platforms: dict[str, MultipanProtocol] = {} + self._store: Store[dict[str, Any]] = Store( + hass, + STORAGE_VERSION_MAJOR, + STORAGE_KEY, + atomic_writes=True, + minor_version=STORAGE_VERSION_MINOR, + ) + + async def async_setup(self) -> None: + """Set up the manager.""" + await async_process_integration_platforms( + self._hass, "silabs_multiprotocol", self._register_multipan_platform + ) + await self.async_load() + + async def _register_multipan_platform( + self, hass: HomeAssistant, integration_domain: str, platform: MultipanProtocol + ) -> None: + """Register a multipan platform.""" + self._platforms[integration_domain] = platform + if self._channel is not None or not await platform.async_using_multipan(hass): + return + + new_channel = await platform.async_get_channel(hass) + if new_channel is None: + return + + _LOGGER.info( + "Setting multipan channel to %s (source: '%s')", + new_channel, + integration_domain, + ) + self.async_set_channel(new_channel) + + async def async_change_channel( + self, channel: int, delay: float + ) -> list[asyncio.Task]: + """Change the channel and notify platforms.""" + self.async_set_channel(channel) + + tasks = [] + + for platform in self._platforms.values(): + if not await platform.async_using_multipan(self._hass): + continue + task = await platform.async_change_channel(self._hass, channel, delay) + if not task: + continue + tasks.append(task) + + return tasks + + @callback + def async_get_channel(self) -> int | None: + """Get the channel.""" + return self._channel + + @callback + def async_set_channel(self, channel: int) -> None: + """Set the channel without notifying platforms. + + This must only be called when first initializing the manager. + """ + self._channel = channel + self.async_schedule_save() + + async def async_load(self) -> None: + """Load the store.""" + data = await self._store.async_load() + + if data is not None: + self._channel = data["channel"] + + @callback + def async_schedule_save(self) -> None: + """Schedule saving the store.""" + self._store.async_delay_save(self._data_to_save, SAVE_DELAY) + + @callback + def _data_to_save(self) -> dict[str, list[dict[str, str | None]]]: + """Return data to store in a file.""" + data: dict[str, Any] = {} + data["channel"] = self._channel + return data + + +class MultipanProtocol(Protocol): + """Define the format of multipan platforms.""" + + async def async_change_channel( + self, hass: HomeAssistant, channel: int, delay: float + ) -> asyncio.Task | None: + """Set the channel to be used. + + Does nothing if not configured or the multiprotocol add-on is not used. + """ + + async def async_get_channel(self, hass: HomeAssistant) -> int | None: + """Return the channel. + + Returns None if not configured or the multiprotocol add-on is not used. + """ + + async def async_using_multipan(self, hass: HomeAssistant) -> bool: + """Return if the multiprotocol device is used. + + Returns False if not configured. + """ @dataclasses.dataclass @@ -82,6 +216,11 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC): def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Set up the options flow.""" + # pylint: disable-next=import-outside-toplevel + from homeassistant.components.zha.radio_manager import ( + ZhaMultiPANMigrationHelper, + ) + # If we install the add-on we should uninstall it on entry remove. self.install_task: asyncio.Task | None = None self.start_task: asyncio.Task | None = None @@ -117,7 +256,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC): async def _async_get_addon_info(self) -> AddonInfo: """Return and cache Silicon Labs Multiprotocol add-on info.""" - addon_manager: AddonManager = get_addon_manager(self.hass) + addon_manager: AddonManager = await get_addon_manager(self.hass) try: addon_info: AddonInfo = await addon_manager.async_get_addon_info() except AddonError as err: @@ -128,7 +267,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC): async def _async_set_addon_config(self, config: dict) -> None: """Set Silicon Labs Multiprotocol add-on config.""" - addon_manager: AddonManager = get_addon_manager(self.hass) + addon_manager: AddonManager = await get_addon_manager(self.hass) try: await addon_manager.async_set_addon_options(config) except AddonError as err: @@ -137,7 +276,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC): async def _async_install_addon(self) -> None: """Install the Silicon Labs Multiprotocol add-on.""" - addon_manager: AddonManager = get_addon_manager(self.hass) + addon_manager: AddonManager = await get_addon_manager(self.hass) try: await addon_manager.async_schedule_install_addon() finally: @@ -213,6 +352,19 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC): self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Configure the Silicon Labs Multiprotocol add-on.""" + # pylint: disable-next=import-outside-toplevel + from homeassistant.components.zha import DOMAIN as ZHA_DOMAIN + + # pylint: disable-next=import-outside-toplevel + from homeassistant.components.zha.radio_manager import ( + ZhaMultiPANMigrationHelper, + ) + + # pylint: disable-next=import-outside-toplevel + from homeassistant.components.zha.silabs_multiprotocol import ( + async_get_channel as async_get_zha_channel, + ) + addon_info = await self._async_get_addon_info() addon_config = addon_info.options @@ -224,6 +376,8 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC): **dataclasses.asdict(serial_port_settings), } + multipan_channel = DEFAULT_CHANNEL + # Initiate ZHA migration zha_entries = self.hass.config_entries.async_entries(ZHA_DOMAIN) @@ -247,6 +401,13 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC): _LOGGER.exception("Unexpected exception during ZHA migration") raise AbortFlow("zha_migration_failed") from err + if (zha_channel := await async_get_zha_channel(self.hass)) is not None: + multipan_channel = zha_channel + + # Initialize the shared channel + multipan_manager = await get_addon_manager(self.hass) + multipan_manager.async_set_channel(multipan_channel) + if new_addon_config != addon_config: # Copy the add-on config to keep the objects separate. self.original_addon_config = dict(addon_config) @@ -283,7 +444,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC): async def _async_start_addon(self) -> None: """Start Silicon Labs Multiprotocol add-on.""" - addon_manager: AddonManager = get_addon_manager(self.hass) + addon_manager: AddonManager = await get_addon_manager(self.hass) try: await addon_manager.async_schedule_start_addon() finally: @@ -319,9 +480,73 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC): serial_device = (await self._async_serial_port_settings()).device if addon_info.options.get(CONF_ADDON_DEVICE) == serial_device: - return await self.async_step_show_revert_guide() + return await self.async_step_show_addon_menu() return await self.async_step_addon_installed_other_device() + async def async_step_show_addon_menu( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Show menu options for the addon.""" + return self.async_show_menu( + step_id="addon_menu", + menu_options=[ + "reconfigure_addon", + "uninstall_addon", + ], + ) + + async def async_step_reconfigure_addon( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Reconfigure the addon.""" + multipan_manager = await get_addon_manager(self.hass) + + if user_input is None: + channels = [str(x) for x in range(11, 27)] + suggested_channel = DEFAULT_CHANNEL + if (channel := multipan_manager.async_get_channel()) is not None: + suggested_channel = channel + data_schema = vol.Schema( + { + vol.Required( + "channel", + description={"suggested_value": str(suggested_channel)}, + ): SelectSelector( + SelectSelectorConfig( + options=channels, mode=SelectSelectorMode.DROPDOWN + ) + ) + } + ) + return self.async_show_form( + step_id="reconfigure_addon", data_schema=data_schema + ) + + # Change the shared channel + await multipan_manager.async_change_channel( + int(user_input["channel"]), DEFAULT_CHANNEL_CHANGE_DELAY + ) + return await self.async_step_notify_channel_change() + + async def async_step_notify_channel_change( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Notify that the channel change will take about five minutes.""" + if user_input is None: + return self.async_show_form( + step_id="notify_channel_change", + description_placeholders={ + "delay_minutes": str(DEFAULT_CHANNEL_CHANGE_DELAY // 60) + }, + ) + return self.async_create_entry(title="", data={}) + + async def async_step_uninstall_addon( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Uninstall the addon (not implemented).""" + return await self.async_step_show_revert_guide() + async def async_step_show_revert_guide( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -348,7 +573,7 @@ async def check_multi_pan_addon(hass: HomeAssistant) -> None: if not is_hassio(hass): return - addon_manager: AddonManager = get_addon_manager(hass) + addon_manager: AddonManager = await get_addon_manager(hass) try: addon_info: AddonInfo = await addon_manager.async_get_addon_info() except AddonError as err: @@ -375,7 +600,7 @@ async def multi_pan_addon_using_device(hass: HomeAssistant, device_path: str) -> if not is_hassio(hass): return False - addon_manager: AddonManager = get_addon_manager(hass) + addon_manager: AddonManager = await get_addon_manager(hass) addon_info: AddonInfo = await addon_manager.async_get_addon_info() if addon_info.state != AddonState.RUNNING: diff --git a/homeassistant/components/homeassistant_hardware/strings.json b/homeassistant/components/homeassistant_hardware/strings.json index 47549794fc8f..605013975572 100644 --- a/homeassistant/components/homeassistant_hardware/strings.json +++ b/homeassistant/components/homeassistant_hardware/strings.json @@ -12,15 +12,34 @@ "addon_installed_other_device": { "title": "Multiprotocol support is already enabled for another device" }, + "addon_menu": { + "menu_options": { + "reconfigure_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::reconfigure_addon::title%]", + "uninstall_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::title%]" + } + }, "install_addon": { "title": "The Silicon Labs Multiprotocol add-on installation has started" }, + "notify_channel_change": { + "title": "Channel change initiated", + "description": "A Zigbee and Thread channel change has been initiated and will finish in {delay_minutes} minutes." + }, + "reconfigure_addon": { + "title": "Reconfigure IEEE 802.15.4 radio multiprotocol support", + "data": { + "channel": "Channel" + } + }, "show_revert_guide": { "title": "Multiprotocol support is enabled for this device", "description": "If you want to change to Zigbee only firmware, please complete the following manual steps:\n\n * Remove the Silicon Labs Multiprotocol addon\n\n * Flash the Zigbee only firmware, follow the guide at https://github.com/NabuCasa/silabs-firmware/wiki/Flash-Silicon-Labs-radio-firmware-manually.\n\n * Reconfigure ZHA to migrate settings to the reflashed radio" }, "start_addon": { "title": "The Silicon Labs Multiprotocol add-on is starting." + }, + "uninstall_addon": { + "title": "Remove IEEE 802.15.4 radio multiprotocol support." } }, "error": { diff --git a/homeassistant/components/homeassistant_sky_connect/strings.json b/homeassistant/components/homeassistant_sky_connect/strings.json index 970f9d97a4c1..415df2092a15 100644 --- a/homeassistant/components/homeassistant_sky_connect/strings.json +++ b/homeassistant/components/homeassistant_sky_connect/strings.json @@ -11,15 +11,34 @@ "addon_installed_other_device": { "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::addon_installed_other_device::title%]" }, + "addon_menu": { + "menu_options": { + "reconfigure_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::addon_menu::menu_options::reconfigure_addon%]", + "uninstall_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::addon_menu::menu_options::uninstall_addon%]" + } + }, "install_addon": { "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::install_addon::title%]" }, + "notify_channel_change": { + "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::notify_channel_change::title%]", + "description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::notify_channel_change::description%]" + }, + "reconfigure_addon": { + "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::reconfigure_addon::title%]", + "data": { + "channel": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::reconfigure_addon::data::channel%]" + } + }, "show_revert_guide": { "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::show_revert_guide::title%]", "description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::show_revert_guide::description%]" }, "start_addon": { "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::start_addon::title%]" + }, + "uninstall_addon": { + "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::title%]" } }, "error": { diff --git a/homeassistant/components/homeassistant_yellow/strings.json b/homeassistant/components/homeassistant_yellow/strings.json index d97b01c7c843..c1069a7e755a 100644 --- a/homeassistant/components/homeassistant_yellow/strings.json +++ b/homeassistant/components/homeassistant_yellow/strings.json @@ -11,6 +11,12 @@ "addon_installed_other_device": { "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::addon_installed_other_device::title%]" }, + "addon_menu": { + "menu_options": { + "reconfigure_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::addon_menu::menu_options::reconfigure_addon%]", + "uninstall_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::addon_menu::menu_options::uninstall_addon%]" + } + }, "hardware_settings": { "title": "Configure hardware settings", "data": { @@ -22,6 +28,10 @@ "install_addon": { "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::install_addon::title%]" }, + "notify_channel_change": { + "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::notify_channel_change::title%]", + "description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::notify_channel_change::description%]" + }, "main_menu": { "menu_options": { "hardware_settings": "[%key:component::homeassistant_yellow::options::step::hardware_settings::title%]", @@ -36,12 +46,21 @@ "reboot_now": "Reboot now" } }, + "reconfigure_addon": { + "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::reconfigure_addon::title%]", + "data": { + "channel": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::reconfigure_addon::data::channel%]" + } + }, "show_revert_guide": { "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::show_revert_guide::title%]", "description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::show_revert_guide::description%]" }, "start_addon": { "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::start_addon::title%]" + }, + "uninstall_addon": { + "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::title%]" } }, "error": { diff --git a/homeassistant/components/otbr/silabs_multiprotocol.py b/homeassistant/components/otbr/silabs_multiprotocol.py new file mode 100644 index 000000000000..9a462c4610bd --- /dev/null +++ b/homeassistant/components/otbr/silabs_multiprotocol.py @@ -0,0 +1,87 @@ +"""Silicon Labs Multiprotocol support.""" + +from __future__ import annotations + +import asyncio +import logging + +import aiohttp +from python_otbr_api import tlv_parser +from python_otbr_api.tlv_parser import MeshcopTLVType + +from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( + is_multiprotocol_url, +) +from homeassistant.components.thread import async_add_dataset +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from . import DOMAIN +from .util import OTBRData + +_LOGGER = logging.getLogger(__name__) + + +async def async_change_channel(hass: HomeAssistant, channel: int, delay: float) -> None: + """Set the channel to be used. + + Does nothing if not configured. + """ + if DOMAIN not in hass.data: + return + + data: OTBRData = hass.data[DOMAIN] + await data.set_channel(channel, delay) + + # Import the new dataset + dataset_tlvs = await data.get_pending_dataset_tlvs() + if dataset_tlvs is None: + # The activation timer may have expired already + dataset_tlvs = await data.get_active_dataset_tlvs() + if dataset_tlvs is None: + # Don't try to import a None dataset + return + + dataset = tlv_parser.parse_tlv(dataset_tlvs.hex()) + dataset.pop(MeshcopTLVType.DELAYTIMER, None) + dataset.pop(MeshcopTLVType.PENDINGTIMESTAMP, None) + dataset_tlvs_str = tlv_parser.encode_tlv(dataset) + await async_add_dataset(hass, DOMAIN, dataset_tlvs_str) + + +async def async_get_channel(hass: HomeAssistant) -> int | None: + """Return the channel. + + Returns None if not configured. + """ + if DOMAIN not in hass.data: + return None + + data: OTBRData = hass.data[DOMAIN] + + try: + dataset = await data.get_active_dataset() + except ( + HomeAssistantError, + aiohttp.ClientError, + asyncio.TimeoutError, + ) as err: + _LOGGER.warning("Failed to communicate with OTBR %s", err) + return None + + if dataset is None: + return None + + return dataset.channel + + +async def async_using_multipan(hass: HomeAssistant) -> bool: + """Return if the multiprotocol device is used. + + Returns False if not configured. + """ + if DOMAIN not in hass.data: + return False + + data: OTBRData = hass.data[DOMAIN] + return is_multiprotocol_url(data.url) diff --git a/homeassistant/components/otbr/util.py b/homeassistant/components/otbr/util.py index 5541ecb68748..5caebba5eb50 100644 --- a/homeassistant/components/otbr/util.py +++ b/homeassistant/components/otbr/util.py @@ -2,22 +2,22 @@ from __future__ import annotations from collections.abc import Callable, Coroutine -import contextlib import dataclasses from functools import wraps from typing import Any, Concatenate, ParamSpec, TypeVar, cast import python_otbr_api -from python_otbr_api import tlv_parser +from python_otbr_api import PENDING_DATASET_DELAY_TIMER, tlv_parser from python_otbr_api.pskc import compute_pskc from python_otbr_api.tlv_parser import MeshcopTLVType from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( + MultiprotocolAddonManager, + get_addon_manager, is_multiprotocol_url, multi_pan_addon_using_device, ) from homeassistant.components.homeassistant_yellow import RADIO_DEVICE as YELLOW_RADIO -from homeassistant.components.zha import api as zha_api from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import issue_registry as ir @@ -73,11 +73,21 @@ class OTBRData: """Enable or disable the router.""" return await self.api.set_enabled(enabled) + @_handle_otbr_error + async def get_active_dataset(self) -> python_otbr_api.ActiveDataSet | None: + """Get current active operational dataset, or None.""" + return await self.api.get_active_dataset() + @_handle_otbr_error async def get_active_dataset_tlvs(self) -> bytes | None: """Get current active operational dataset in TLVS format, or None.""" return await self.api.get_active_dataset_tlvs() + @_handle_otbr_error + async def get_pending_dataset_tlvs(self) -> bytes | None: + """Get current pending operational dataset in TLVS format, or None.""" + return await self.api.get_pending_dataset_tlvs() + @_handle_otbr_error async def create_active_dataset( self, dataset: python_otbr_api.ActiveDataSet @@ -90,43 +100,27 @@ class OTBRData: """Set current active operational dataset in TLVS format.""" await self.api.set_active_dataset_tlvs(dataset) + @_handle_otbr_error + async def set_channel( + self, channel: int, delay: float = PENDING_DATASET_DELAY_TIMER / 1000 + ) -> None: + """Set current channel.""" + await self.api.set_channel(channel, delay=int(delay * 1000)) + @_handle_otbr_error async def get_extended_address(self) -> bytes: """Get extended address (EUI-64).""" return await self.api.get_extended_address() -def _get_zha_url(hass: HomeAssistant) -> str | None: - """Get ZHA radio path, or None if there's no ZHA config entry.""" - with contextlib.suppress(ValueError): - return zha_api.async_get_radio_path(hass) - return None - - -async def _get_zha_channel(hass: HomeAssistant) -> int | None: - """Get ZHA channel, or None if there's no ZHA config entry.""" - zha_network_settings: zha_api.NetworkBackup | None - with contextlib.suppress(ValueError): - zha_network_settings = await zha_api.async_get_network_settings(hass) - if not zha_network_settings: - return None - channel: int = zha_network_settings.network_info.channel - # ZHA uses channel 0 when no channel is set - return channel or None - - async def get_allowed_channel(hass: HomeAssistant, otbr_url: str) -> int | None: """Return the allowed channel, or None if there's no restriction.""" if not is_multiprotocol_url(otbr_url): # The OTBR is not sharing the radio, no restriction return None - zha_url = _get_zha_url(hass) - if not zha_url or not is_multiprotocol_url(zha_url): - # ZHA is not configured or not sharing the radio with this OTBR, no restriction - return None - - return await _get_zha_channel(hass) + addon_manager: MultiprotocolAddonManager = await get_addon_manager(hass) + return addon_manager.async_get_channel() async def _warn_on_channel_collision( diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index d0f124a08388..91bc2ac42a2b 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -96,7 +96,7 @@ async def list_serial_ports(hass: HomeAssistant) -> list[ListPortInfo]: yellow_radio.manufacturer = "Nabu Casa" # Present the multi-PAN addon as a setup option, if it's available - addon_manager = silabs_multiprotocol_addon.get_addon_manager(hass) + addon_manager = await silabs_multiprotocol_addon.get_addon_manager(hass) try: addon_info = await addon_manager.async_get_addon_info() diff --git a/homeassistant/components/zha/silabs_multiprotocol.py b/homeassistant/components/zha/silabs_multiprotocol.py new file mode 100644 index 000000000000..aec52b4ac751 --- /dev/null +++ b/homeassistant/components/zha/silabs_multiprotocol.py @@ -0,0 +1,81 @@ +"""Silicon Labs Multiprotocol support.""" + +from __future__ import annotations + +import asyncio +import contextlib + +from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( + is_multiprotocol_url, +) +from homeassistant.core import HomeAssistant + +from . import api + +# The approximate time it takes ZHA to change channels on SiLabs coordinators +ZHA_CHANNEL_CHANGE_TIME_S = 10.27 + + +def _get_zha_url(hass: HomeAssistant) -> str | None: + """Return the ZHA radio path, or None if there's no ZHA config entry.""" + with contextlib.suppress(ValueError): + return api.async_get_radio_path(hass) + return None + + +async def _get_zha_channel(hass: HomeAssistant) -> int | None: + """Get ZHA channel, or None if there's no ZHA config entry.""" + zha_network_settings: api.NetworkBackup | None + with contextlib.suppress(ValueError): + zha_network_settings = await api.async_get_network_settings(hass) + if not zha_network_settings: + return None + channel: int = zha_network_settings.network_info.channel + # ZHA uses channel 0 when no channel is set + return channel or None + + +async def async_change_channel( + hass: HomeAssistant, channel: int, delay: float = 0 +) -> asyncio.Task | None: + """Set the channel to be used. + + Does nothing if not configured. + """ + zha_url = _get_zha_url(hass) + if not zha_url: + # ZHA is not configured + return None + + async def finish_migration() -> None: + """Finish the channel migration.""" + await asyncio.sleep(max(0, delay - ZHA_CHANNEL_CHANGE_TIME_S)) + return await api.async_change_channel(hass, channel) + + return hass.async_create_task(finish_migration()) + + +async def async_get_channel(hass: HomeAssistant) -> int | None: + """Return the channel. + + Returns None if not configured. + """ + zha_url = _get_zha_url(hass) + if not zha_url: + # ZHA is not configured + return None + + return await _get_zha_channel(hass) + + +async def async_using_multipan(hass: HomeAssistant) -> bool: + """Return if the multiprotocol device is used. + + Returns False if not configured. + """ + zha_url = _get_zha_url(hass) + if not zha_url: + # ZHA is not configured + return False + + return is_multiprotocol_url(zha_url) diff --git a/tests/components/homeassistant_hardware/conftest.py b/tests/components/homeassistant_hardware/conftest.py index 4add48781a9d..60c766c7204c 100644 --- a/tests/components/homeassistant_hardware/conftest.py +++ b/tests/components/homeassistant_hardware/conftest.py @@ -1,7 +1,7 @@ """Test fixtures for the Home Assistant Hardware integration.""" from collections.abc import Generator from typing import Any -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -32,6 +32,17 @@ def mock_zha_config_flow_setup() -> Generator[None, None, None]: yield +@pytest.fixture(autouse=True) +def mock_zha_get_last_network_settings() -> Generator[None, None, None]: + """Mock zha.api.async_get_last_network_settings.""" + + with patch( + "homeassistant.components.zha.api.async_get_last_network_settings", + AsyncMock(return_value=None), + ): + yield + + @pytest.fixture(name="addon_running") def mock_addon_running(addon_store_info, addon_info): """Mock add-on already running.""" diff --git a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py index a195899136dc..83702adcc3aa 100644 --- a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py +++ b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py @@ -11,12 +11,16 @@ from homeassistant.components.hassio.handler import HassioAPIError from homeassistant.components.homeassistant_hardware import silabs_multiprotocol_addon from homeassistant.components.zha.core.const import DOMAIN as ZHA_DOMAIN from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.const import EVENT_COMPONENT_LOADED from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult, FlowResultType +from homeassistant.setup import ATTR_COMPONENT from tests.common import ( MockConfigEntry, MockModule, + MockPlatform, + flush_store, mock_config_flow, mock_integration, mock_platform, @@ -96,6 +100,54 @@ def config_flow_handler( yield +class MockMultiprotocolPlatform(MockPlatform): + """A mock multiprotocol platform.""" + + channel = 15 + using_multipan = True + + def __init__(self, **kwargs: Any) -> None: + """Initialize.""" + super().__init__(**kwargs) + self.change_channel_calls = [] + + async def async_change_channel( + self, hass: HomeAssistant, channel: int, delay: float + ) -> None: + """Set the channel to be used.""" + self.change_channel_calls.append((channel, delay)) + + async def async_get_channel(self, hass: HomeAssistant) -> int | None: + """Return the channel.""" + return self.channel + + async def async_using_multipan(self, hass: HomeAssistant) -> bool: + """Return if the multiprotocol device is used.""" + return self.using_multipan + + +@pytest.fixture +def mock_multiprotocol_platform( + hass: HomeAssistant, +) -> Generator[FakeConfigFlow, None, None]: + """Fixture for a test silabs multiprotocol platform.""" + hass.config.components.add(TEST_DOMAIN) + platform = MockMultiprotocolPlatform() + mock_platform(hass, f"{TEST_DOMAIN}.silabs_multiprotocol", platform) + return platform + + +def get_suggested(schema, key): + """Get suggested value for key in voluptuous schema.""" + for k in schema: + if k == key: + if k.description is None or "suggested_value" not in k.description: + return None + return k.description["suggested_value"] + # Wanted key absent from schema + raise Exception + + async def test_option_flow_install_multi_pan_addon( hass: HomeAssistant, addon_store_info, @@ -215,7 +267,13 @@ async def test_option_flow_install_multi_pan_addon_zha( assert result["step_id"] == "configure_addon" install_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") - result = await hass.config_entries.options.async_configure(result["flow_id"]) + multipan_manager = await silabs_multiprotocol_addon.get_addon_manager(hass) + assert multipan_manager._channel is None + with patch( + "homeassistant.components.zha.silabs_multiprotocol.async_get_channel", + return_value=11, + ): + result = await hass.config_entries.options.async_configure(result["flow_id"]) assert result["type"] == FlowResultType.SHOW_PROGRESS assert result["step_id"] == "start_addon" set_addon_options.assert_called_once_with( @@ -230,6 +288,8 @@ async def test_option_flow_install_multi_pan_addon_zha( } }, ) + # Check the channel is initialized from ZHA + assert multipan_manager._channel == 11 # Check the ZHA config entry data is updated assert zha_config_entry.data == { "device": { @@ -393,7 +453,64 @@ async def test_option_flow_addon_installed_other_device( assert result["type"] == FlowResultType.CREATE_ENTRY -async def test_option_flow_addon_installed_same_device( +@pytest.mark.parametrize( + ("configured_channel", "suggested_channel"), [(None, "15"), (11, "11")] +) +async def test_option_flow_addon_installed_same_device_reconfigure( + hass: HomeAssistant, + addon_info, + addon_store_info, + addon_installed, + mock_multiprotocol_platform: MockMultiprotocolPlatform, + configured_channel: int | None, + suggested_channel: int, +) -> None: + """Test installing the multi pan addon.""" + mock_integration(hass, MockModule("hassio")) + addon_info.return_value["options"]["device"] = "/dev/ttyTEST123" + + multipan_manager = await silabs_multiprotocol_addon.get_addon_manager(hass) + multipan_manager._channel = configured_channel + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=TEST_DOMAIN, + options={}, + title="Test HW", + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.is_hassio", + side_effect=Mock(return_value=True), + ): + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "addon_menu" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"next_step_id": "reconfigure_addon"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reconfigure_addon" + assert get_suggested(result["data_schema"].schema, "channel") == suggested_channel + + result = await hass.config_entries.options.async_configure( + result["flow_id"], {"channel": "14"} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "notify_channel_change" + assert result["description_placeholders"] == {"delay_minutes": "5"} + + result = await hass.config_entries.options.async_configure(result["flow_id"], {}) + assert result["type"] == FlowResultType.CREATE_ENTRY + + assert mock_multiprotocol_platform.change_channel_calls == [(14, 300)] + + +async def test_option_flow_addon_installed_same_device_uninstall( hass: HomeAssistant, addon_info, addon_store_info, @@ -417,8 +534,15 @@ async def test_option_flow_addon_installed_same_device( side_effect=Mock(return_value=True), ): result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "show_revert_guide" + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "addon_menu" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"next_step_id": "uninstall_addon"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "show_revert_guide" result = await hass.config_entries.options.async_configure(result["flow_id"], {}) assert result["type"] == FlowResultType.CREATE_ENTRY @@ -806,3 +930,80 @@ def test_is_multiprotocol_url() -> None: "http://core-silabs-multiprotocol:8081" ) assert not silabs_multiprotocol_addon.is_multiprotocol_url("/dev/ttyAMA1") + + +@pytest.mark.parametrize( + ( + "initial_multipan_channel", + "platform_using_multipan", + "platform_channel", + "new_multipan_channel", + ), + [ + (None, True, 15, 15), + (None, False, 15, None), + (11, True, 15, 11), + (None, True, None, None), + ], +) +async def test_import_channel( + hass: HomeAssistant, + initial_multipan_channel: int | None, + platform_using_multipan: bool, + platform_channel: int | None, + new_multipan_channel: int | None, +) -> None: + """Test channel is initialized from first platform.""" + multipan_manager = await silabs_multiprotocol_addon.get_addon_manager(hass) + multipan_manager._channel = initial_multipan_channel + + mock_multiprotocol_platform = MockMultiprotocolPlatform() + mock_multiprotocol_platform.channel = platform_channel + mock_multiprotocol_platform.using_multipan = platform_using_multipan + + hass.config.components.add(TEST_DOMAIN) + mock_platform( + hass, f"{TEST_DOMAIN}.silabs_multiprotocol", mock_multiprotocol_platform + ) + hass.bus.async_fire(EVENT_COMPONENT_LOADED, {ATTR_COMPONENT: TEST_DOMAIN}) + await hass.async_block_till_done() + + assert multipan_manager.async_get_channel() == new_multipan_channel + + +@pytest.mark.parametrize( + ( + "platform_using_multipan", + "expected_calls", + ), + [ + (True, [(15, 10)]), + (False, []), + ], +) +async def test_change_channel( + hass: HomeAssistant, + mock_multiprotocol_platform: MockMultiprotocolPlatform, + platform_using_multipan: bool, + expected_calls: list[int], +) -> None: + """Test channel is initialized from first platform.""" + multipan_manager = await silabs_multiprotocol_addon.get_addon_manager(hass) + mock_multiprotocol_platform.using_multipan = platform_using_multipan + + await multipan_manager.async_change_channel(15, 10) + assert mock_multiprotocol_platform.change_channel_calls == expected_calls + + +async def test_load_preferences(hass: HomeAssistant) -> None: + """Make sure that we can load/save data correctly.""" + multipan_manager = await silabs_multiprotocol_addon.get_addon_manager(hass) + assert multipan_manager._channel != 11 + multipan_manager.async_set_channel(11) + + await flush_store(multipan_manager._store) + + multipan_manager2 = silabs_multiprotocol_addon.MultiprotocolAddonManager(hass) + await multipan_manager2.async_setup() + + assert multipan_manager._channel == multipan_manager2._channel diff --git a/tests/components/homeassistant_sky_connect/conftest.py b/tests/components/homeassistant_sky_connect/conftest.py index 7fcc1f868805..3677b4ea8f1d 100644 --- a/tests/components/homeassistant_sky_connect/conftest.py +++ b/tests/components/homeassistant_sky_connect/conftest.py @@ -1,6 +1,6 @@ """Test fixtures for the Home Assistant SkyConnect integration.""" from collections.abc import Generator -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -34,6 +34,17 @@ def mock_zha(): yield +@pytest.fixture(autouse=True) +def mock_zha_get_last_network_settings() -> Generator[None, None, None]: + """Mock zha.api.async_get_last_network_settings.""" + + with patch( + "homeassistant.components.zha.api.async_get_last_network_settings", + AsyncMock(return_value=None), + ): + yield + + @pytest.fixture(name="addon_running") def mock_addon_running(addon_store_info, addon_info): """Mock add-on already running.""" diff --git a/tests/components/homeassistant_yellow/conftest.py b/tests/components/homeassistant_yellow/conftest.py index bc48c6b01fd3..e4a666f9f04f 100644 --- a/tests/components/homeassistant_yellow/conftest.py +++ b/tests/components/homeassistant_yellow/conftest.py @@ -1,7 +1,7 @@ """Test fixtures for the Home Assistant Yellow integration.""" from collections.abc import Generator from typing import Any -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -32,6 +32,17 @@ def mock_zha_config_flow_setup() -> Generator[None, None, None]: yield +@pytest.fixture(autouse=True) +def mock_zha_get_last_network_settings() -> Generator[None, None, None]: + """Mock zha.api.async_get_last_network_settings.""" + + with patch( + "homeassistant.components.zha.api.async_get_last_network_settings", + AsyncMock(return_value=None), + ): + yield + + @pytest.fixture(name="addon_running") def mock_addon_running(addon_store_info, addon_info): """Mock add-on already running.""" diff --git a/tests/components/otbr/conftest.py b/tests/components/otbr/conftest.py index f0b3ca0a18d2..bb3b474519ec 100644 --- a/tests/components/otbr/conftest.py +++ b/tests/components/otbr/conftest.py @@ -1,9 +1,10 @@ """Test fixtures for the Open Thread Border Router integration.""" -from unittest.mock import patch +from unittest.mock import Mock, patch import pytest from homeassistant.components import otbr +from homeassistant.core import HomeAssistant from . import CONFIG_ENTRY_DATA, DATASET_CH16 @@ -31,3 +32,12 @@ async def otbr_config_entry_fixture(hass): @pytest.fixture(autouse=True) def use_mocked_zeroconf(mock_async_zeroconf): """Mock zeroconf in all tests.""" + + +@pytest.fixture(name="multiprotocol_addon_manager_mock") +def multiprotocol_addon_manager_mock_fixture(hass: HomeAssistant): + """Mock the Silicon Labs Multiprotocol add-on manager.""" + mock_manager = Mock() + mock_manager.async_get_channel = Mock(return_value=None) + with patch.dict(hass.data, {"silabs_multiprotocol_addon_manager": mock_manager}): + yield mock_manager diff --git a/tests/components/otbr/test_config_flow.py b/tests/components/otbr/test_config_flow.py index faec90282df0..cfb47a28bcf6 100644 --- a/tests/components/otbr/test_config_flow.py +++ b/tests/components/otbr/test_config_flow.py @@ -2,7 +2,7 @@ import asyncio from http import HTTPStatus from typing import Any -from unittest.mock import Mock, patch +from unittest.mock import patch import aiohttp import pytest @@ -309,7 +309,9 @@ async def test_hassio_discovery_flow_router_not_setup_has_preferred( async def test_hassio_discovery_flow_router_not_setup_has_preferred_2( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + multiprotocol_addon_manager_mock, ) -> None: """Test the hassio discovery flow when the border router has no dataset. @@ -321,8 +323,7 @@ async def test_hassio_discovery_flow_router_not_setup_has_preferred_2( aioclient_mock.put(f"{url}/node/dataset/active", status=HTTPStatus.CREATED) aioclient_mock.put(f"{url}/node/state", status=HTTPStatus.OK) - networksettings = Mock() - networksettings.network_info.channel = 15 + multiprotocol_addon_manager_mock.async_get_channel.return_value = 15 with patch( "homeassistant.components.otbr.config_flow.async_get_preferred_dataset", @@ -330,13 +331,7 @@ async def test_hassio_discovery_flow_router_not_setup_has_preferred_2( ), patch( "homeassistant.components.otbr.async_setup_entry", return_value=True, - ) as mock_setup_entry, patch( - "homeassistant.components.otbr.util.zha_api.async_get_radio_path", - return_value="socket://core-silabs-multiprotocol:9999", - ), patch( - "homeassistant.components.otbr.util.zha_api.async_get_network_settings", - return_value=networksettings, - ): + ) as mock_setup_entry: result = await hass.config_entries.flow.async_init( otbr.DOMAIN, context={"source": "hassio"}, data=HASSIO_DATA ) diff --git a/tests/components/otbr/test_init.py b/tests/components/otbr/test_init.py index 3d646287ce17..990c015244fc 100644 --- a/tests/components/otbr/test_init.py +++ b/tests/components/otbr/test_init.py @@ -1,7 +1,7 @@ """Test the Open Thread Border Router integration.""" import asyncio from http import HTTPStatus -from unittest.mock import ANY, AsyncMock, MagicMock, Mock, patch +from unittest.mock import ANY, AsyncMock, MagicMock, patch import aiohttp import pytest @@ -59,7 +59,9 @@ async def test_import_dataset(hass: HomeAssistant) -> None: ) -async def test_import_share_radio_channel_collision(hass: HomeAssistant) -> None: +async def test_import_share_radio_channel_collision( + hass: HomeAssistant, multiprotocol_addon_manager_mock +) -> None: """Test the active dataset is imported at setup. This imports a dataset with different channel than ZHA when ZHA and OTBR share @@ -67,8 +69,7 @@ async def test_import_share_radio_channel_collision(hass: HomeAssistant) -> None """ issue_registry = ir.async_get(hass) - networksettings = Mock() - networksettings.network_info.channel = 15 + multiprotocol_addon_manager_mock.async_get_channel.return_value = 15 config_entry = MockConfigEntry( data=CONFIG_ENTRY_DATA, @@ -81,13 +82,7 @@ async def test_import_share_radio_channel_collision(hass: HomeAssistant) -> None "python_otbr_api.OTBR.get_active_dataset_tlvs", return_value=DATASET_CH16 ), patch( "homeassistant.components.thread.dataset_store.DatasetStore.async_add" - ) as mock_add, patch( - "homeassistant.components.otbr.util.zha_api.async_get_radio_path", - return_value="socket://core-silabs-multiprotocol:9999", - ), patch( - "homeassistant.components.otbr.util.zha_api.async_get_network_settings", - return_value=networksettings, - ): + ) as mock_add: assert await hass.config_entries.async_setup(config_entry.entry_id) mock_add.assert_called_once_with(otbr.DOMAIN, DATASET_CH16.hex()) @@ -99,7 +94,7 @@ async def test_import_share_radio_channel_collision(hass: HomeAssistant) -> None @pytest.mark.parametrize("dataset", [DATASET_CH15, DATASET_NO_CHANNEL]) async def test_import_share_radio_no_channel_collision( - hass: HomeAssistant, dataset: bytes + hass: HomeAssistant, multiprotocol_addon_manager_mock, dataset: bytes ) -> None: """Test the active dataset is imported at setup. @@ -107,8 +102,7 @@ async def test_import_share_radio_no_channel_collision( """ issue_registry = ir.async_get(hass) - networksettings = Mock() - networksettings.network_info.channel = 15 + multiprotocol_addon_manager_mock.async_get_channel.return_value = 15 config_entry = MockConfigEntry( data=CONFIG_ENTRY_DATA, @@ -121,13 +115,7 @@ async def test_import_share_radio_no_channel_collision( "python_otbr_api.OTBR.get_active_dataset_tlvs", return_value=dataset ), patch( "homeassistant.components.thread.dataset_store.DatasetStore.async_add" - ) as mock_add, patch( - "homeassistant.components.otbr.util.zha_api.async_get_radio_path", - return_value="socket://core-silabs-multiprotocol:9999", - ), patch( - "homeassistant.components.otbr.util.zha_api.async_get_network_settings", - return_value=networksettings, - ): + ) as mock_add: assert await hass.config_entries.async_setup(config_entry.entry_id) mock_add.assert_called_once_with(otbr.DOMAIN, dataset.hex()) diff --git a/tests/components/otbr/test_silabs_multiprotocol.py b/tests/components/otbr/test_silabs_multiprotocol.py new file mode 100644 index 000000000000..8dd07db6f22e --- /dev/null +++ b/tests/components/otbr/test_silabs_multiprotocol.py @@ -0,0 +1,175 @@ +"""Test OTBR Silicon Labs Multiprotocol support.""" +from unittest.mock import patch + +import pytest +from python_otbr_api import ActiveDataSet, tlv_parser + +from homeassistant.components import otbr +from homeassistant.components.otbr import ( + silabs_multiprotocol as otbr_silabs_multiprotocol, +) +from homeassistant.components.thread import dataset_store +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from . import DATASET_CH16 + +OTBR_MULTIPAN_URL = "http://core-silabs-multiprotocol:8081" +OTBR_NON_MULTIPAN_URL = "/dev/ttyAMA1" +DATASET_CH16_PENDING = ( + "0E080000000000020000" # ACTIVETIMESTAMP + "340400006699" # DELAYTIMER + "000300000F" # CHANNEL + "35060004001FFFE0" # CHANNELMASK + "0208F642646DA209B1C0" # EXTPANID + "0708FDF57B5A0FE2AAF6" # MESHLOCALPREFIX + "0510DE98B5BA1A528FEE049D4B4B01835375" # NETWORKKEY + "030D4F70656E546872656164204841" # NETWORKNAME + "010225A4" # PANID + "0410F5DD18371BFD29E1A601EF6FFAD94C03" # PSKC + "0C0402A0F7F8" # SECURITYPOLICY +) + + +async def test_async_change_channel(hass: HomeAssistant, otbr_config_entry) -> None: + """Test test_async_change_channel.""" + + store = await dataset_store.async_get_store(hass) + assert len(store.datasets) == 1 + assert list(store.datasets.values())[0].tlv == DATASET_CH16.hex() + + with patch("python_otbr_api.OTBR.set_channel") as mock_set_channel, patch( + "python_otbr_api.OTBR.get_pending_dataset_tlvs", + return_value=bytes.fromhex(DATASET_CH16_PENDING), + ): + await otbr_silabs_multiprotocol.async_change_channel(hass, 15, delay=5 * 300) + mock_set_channel.assert_awaited_once_with(15, delay=5 * 300 * 1000) + + pending_dataset = tlv_parser.parse_tlv(DATASET_CH16_PENDING) + pending_dataset.pop(tlv_parser.MeshcopTLVType.DELAYTIMER) + + assert len(store.datasets) == 1 + assert list(store.datasets.values())[0].tlv == tlv_parser.encode_tlv( + pending_dataset + ) + + +async def test_async_change_channel_no_pending( + hass: HomeAssistant, otbr_config_entry +) -> None: + """Test test_async_change_channel when the pending dataset already expired.""" + + store = await dataset_store.async_get_store(hass) + assert len(store.datasets) == 1 + assert list(store.datasets.values())[0].tlv == DATASET_CH16.hex() + + with patch("python_otbr_api.OTBR.set_channel") as mock_set_channel, patch( + "python_otbr_api.OTBR.get_active_dataset_tlvs", + return_value=bytes.fromhex(DATASET_CH16_PENDING), + ), patch( + "python_otbr_api.OTBR.get_pending_dataset_tlvs", + return_value=None, + ): + await otbr_silabs_multiprotocol.async_change_channel(hass, 15, delay=5 * 300) + mock_set_channel.assert_awaited_once_with(15, delay=5 * 300 * 1000) + + pending_dataset = tlv_parser.parse_tlv(DATASET_CH16_PENDING) + pending_dataset.pop(tlv_parser.MeshcopTLVType.DELAYTIMER) + + assert len(store.datasets) == 1 + assert list(store.datasets.values())[0].tlv == tlv_parser.encode_tlv( + pending_dataset + ) + + +async def test_async_change_channel_no_update( + hass: HomeAssistant, otbr_config_entry +) -> None: + """Test test_async_change_channel when we didn't get a dataset from the OTBR.""" + + store = await dataset_store.async_get_store(hass) + assert len(store.datasets) == 1 + assert list(store.datasets.values())[0].tlv == DATASET_CH16.hex() + + with patch("python_otbr_api.OTBR.set_channel") as mock_set_channel, patch( + "python_otbr_api.OTBR.get_active_dataset_tlvs", + return_value=None, + ), patch( + "python_otbr_api.OTBR.get_pending_dataset_tlvs", + return_value=None, + ): + await otbr_silabs_multiprotocol.async_change_channel(hass, 15, delay=5 * 300) + mock_set_channel.assert_awaited_once_with(15, delay=5 * 300 * 1000) + + assert list(store.datasets.values())[0].tlv == DATASET_CH16.hex() + + +async def test_async_change_channel_no_otbr(hass: HomeAssistant) -> None: + """Test async_change_channel when otbr is not configured.""" + + with patch("python_otbr_api.OTBR.set_channel") as mock_set_channel: + await otbr_silabs_multiprotocol.async_change_channel(hass, 16, delay=0) + mock_set_channel.assert_not_awaited() + + +async def test_async_get_channel(hass: HomeAssistant, otbr_config_entry) -> None: + """Test test_async_get_channel.""" + + with patch( + "python_otbr_api.OTBR.get_active_dataset", + return_value=ActiveDataSet(channel=11), + ) as mock_get_active_dataset: + assert await otbr_silabs_multiprotocol.async_get_channel(hass) == 11 + mock_get_active_dataset.assert_awaited_once_with() + + +async def test_async_get_channel_no_dataset( + hass: HomeAssistant, otbr_config_entry +) -> None: + """Test test_async_get_channel.""" + + with patch( + "python_otbr_api.OTBR.get_active_dataset", + return_value=None, + ) as mock_get_active_dataset: + assert await otbr_silabs_multiprotocol.async_get_channel(hass) is None + mock_get_active_dataset.assert_awaited_once_with() + + +async def test_async_get_channel_error(hass: HomeAssistant, otbr_config_entry) -> None: + """Test test_async_get_channel.""" + + with patch( + "python_otbr_api.OTBR.get_active_dataset", + side_effect=HomeAssistantError, + ) as mock_get_active_dataset: + assert await otbr_silabs_multiprotocol.async_get_channel(hass) is None + mock_get_active_dataset.assert_awaited_once_with() + + +async def test_async_get_channel_no_otbr(hass: HomeAssistant) -> None: + """Test test_async_get_channel when otbr is not configured.""" + + with patch("python_otbr_api.OTBR.get_active_dataset") as mock_get_active_dataset: + await otbr_silabs_multiprotocol.async_get_channel(hass) + mock_get_active_dataset.assert_not_awaited() + + +@pytest.mark.parametrize( + ("url", "expected"), + [(OTBR_MULTIPAN_URL, True), (OTBR_NON_MULTIPAN_URL, False)], +) +async def test_async_using_multipan( + hass: HomeAssistant, otbr_config_entry, url: str, expected: bool +) -> None: + """Test async_change_channel when otbr is not configured.""" + data: otbr.OTBRData = hass.data[otbr.DOMAIN] + data.url = url + + assert await otbr_silabs_multiprotocol.async_using_multipan(hass) is expected + + +async def test_async_using_multipan_no_otbr(hass: HomeAssistant) -> None: + """Test async_change_channel when otbr is not configured.""" + + assert await otbr_silabs_multiprotocol.async_using_multipan(hass) is False diff --git a/tests/components/otbr/test_util.py b/tests/components/otbr/test_util.py index af5306b3581b..f8ed79b91ee3 100644 --- a/tests/components/otbr/test_util.py +++ b/tests/components/otbr/test_util.py @@ -1,5 +1,4 @@ """Test OTBR Utility functions.""" -from unittest.mock import Mock, patch from homeassistant.components import otbr from homeassistant.core import HomeAssistant @@ -8,51 +7,19 @@ OTBR_MULTIPAN_URL = "http://core-silabs-multiprotocol:8081" OTBR_NON_MULTIPAN_URL = "/dev/ttyAMA1" -async def test_get_allowed_channel(hass: HomeAssistant) -> None: +async def test_get_allowed_channel( + hass: HomeAssistant, multiprotocol_addon_manager_mock +) -> None: """Test get_allowed_channel.""" - zha_networksettings = Mock() - zha_networksettings.network_info.channel = 15 - - # OTBR multipan + No ZHA -> no restriction + # OTBR multipan + No configured channel -> no restriction + multiprotocol_addon_manager_mock.async_get_channel.return_value = None assert await otbr.util.get_allowed_channel(hass, OTBR_MULTIPAN_URL) is None - # OTBR multipan + ZHA multipan empty settings -> no restriction - with patch( - "homeassistant.components.otbr.util.zha_api.async_get_radio_path", - return_value="socket://core-silabs-multiprotocol:9999", - ), patch( - "homeassistant.components.otbr.util.zha_api.async_get_network_settings", - return_value=None, - ): - assert await otbr.util.get_allowed_channel(hass, OTBR_MULTIPAN_URL) is None + # OTBR multipan + multipan using channel 15 -> 15 + multiprotocol_addon_manager_mock.async_get_channel.return_value = 15 + assert await otbr.util.get_allowed_channel(hass, OTBR_MULTIPAN_URL) == 15 - # OTBR multipan + ZHA not multipan using channel 15 -> no restriction - with patch( - "homeassistant.components.otbr.util.zha_api.async_get_radio_path", - return_value="/dev/ttyAMA1", - ), patch( - "homeassistant.components.otbr.util.zha_api.async_get_network_settings", - return_value=zha_networksettings, - ): - assert await otbr.util.get_allowed_channel(hass, OTBR_MULTIPAN_URL) is None - - # OTBR multipan + ZHA multipan using channel 15 -> 15 - with patch( - "homeassistant.components.otbr.util.zha_api.async_get_radio_path", - return_value="socket://core-silabs-multiprotocol:9999", - ), patch( - "homeassistant.components.otbr.util.zha_api.async_get_network_settings", - return_value=zha_networksettings, - ): - assert await otbr.util.get_allowed_channel(hass, OTBR_MULTIPAN_URL) == 15 - - # OTBR not multipan + ZHA multipan using channel 15 -> no restriction - with patch( - "homeassistant.components.otbr.util.zha_api.async_get_radio_path", - return_value="socket://core-silabs-multiprotocol:9999", - ), patch( - "homeassistant.components.otbr.util.zha_api.async_get_network_settings", - return_value=zha_networksettings, - ): - assert await otbr.util.get_allowed_channel(hass, OTBR_NON_MULTIPAN_URL) is None + # OTBR no multipan + multipan using channel 15 -> no restriction + multiprotocol_addon_manager_mock.async_get_channel.return_value = 15 + assert await otbr.util.get_allowed_channel(hass, OTBR_NON_MULTIPAN_URL) is None diff --git a/tests/components/otbr/test_websocket_api.py b/tests/components/otbr/test_websocket_api.py index bfc3f09d6feb..1feebe9c02c1 100644 --- a/tests/components/otbr/test_websocket_api.py +++ b/tests/components/otbr/test_websocket_api.py @@ -1,5 +1,5 @@ """Test OTBR Websocket API.""" -from unittest.mock import Mock, patch +from unittest.mock import patch import pytest import python_otbr_api @@ -273,6 +273,7 @@ async def test_set_network_no_entry( async def test_set_network_channel_conflict( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, + multiprotocol_addon_manager_mock, otbr_config_entry, websocket_client, ) -> None: @@ -281,24 +282,16 @@ async def test_set_network_channel_conflict( dataset_store = await thread.dataset_store.async_get_store(hass) dataset_id = list(dataset_store.datasets)[0] - networksettings = Mock() - networksettings.network_info.channel = 15 + multiprotocol_addon_manager_mock.async_get_channel.return_value = 15 - with patch( - "homeassistant.components.otbr.util.zha_api.async_get_radio_path", - return_value="socket://core-silabs-multiprotocol:9999", - ), patch( - "homeassistant.components.otbr.util.zha_api.async_get_network_settings", - return_value=networksettings, - ): - await websocket_client.send_json_auto_id( - { - "type": "otbr/set_network", - "dataset_id": dataset_id, - } - ) + await websocket_client.send_json_auto_id( + { + "type": "otbr/set_network", + "dataset_id": dataset_id, + } + ) - msg = await websocket_client.receive_json() + msg = await websocket_client.receive_json() assert not msg["success"] assert msg["error"]["code"] == "channel_conflict" diff --git a/tests/components/zha/test_silabs_multiprotocol.py b/tests/components/zha/test_silabs_multiprotocol.py new file mode 100644 index 000000000000..beae0230901e --- /dev/null +++ b/tests/components/zha/test_silabs_multiprotocol.py @@ -0,0 +1,118 @@ +"""Test ZHA Silicon Labs Multiprotocol support.""" +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import call, patch + +import pytest +import zigpy.backups +import zigpy.state + +from homeassistant.components import zha +from homeassistant.components.zha import api, silabs_multiprotocol +from homeassistant.core import HomeAssistant + +if TYPE_CHECKING: + from zigpy.application import ControllerApplication + + +@pytest.fixture(autouse=True) +def required_platform_only(): + """Only set up the required and required base platforms to speed up tests.""" + with patch("homeassistant.components.zha.PLATFORMS", ()): + yield + + +async def test_async_get_channel_active(hass: HomeAssistant, setup_zha) -> None: + """Test reading channel with an active ZHA installation.""" + await setup_zha() + + assert await silabs_multiprotocol.async_get_channel(hass) == 15 + + +async def test_async_get_channel_missing( + hass: HomeAssistant, setup_zha, zigpy_app_controller: ControllerApplication +) -> None: + """Test reading channel with an inactive ZHA installation, no valid channel.""" + await setup_zha() + + gateway = api._get_gateway(hass) + await zha.async_unload_entry(hass, gateway.config_entry) + + # Network settings were never loaded for whatever reason + zigpy_app_controller.state.network_info = zigpy.state.NetworkInfo() + zigpy_app_controller.state.node_info = zigpy.state.NodeInfo() + + with patch( + "bellows.zigbee.application.ControllerApplication.__new__", + return_value=zigpy_app_controller, + ): + assert await silabs_multiprotocol.async_get_channel(hass) is None + + +async def test_async_get_channel_no_zha(hass: HomeAssistant) -> None: + """Test reading channel with no ZHA config entries and no database.""" + assert await silabs_multiprotocol.async_get_channel(hass) is None + + +async def test_async_using_multipan_active(hass: HomeAssistant, setup_zha) -> None: + """Test async_using_multipan with an active ZHA installation.""" + await setup_zha() + + assert await silabs_multiprotocol.async_using_multipan(hass) is False + + +async def test_async_using_multipan_no_zha(hass: HomeAssistant) -> None: + """Test async_using_multipan with no ZHA config entries and no database.""" + assert await silabs_multiprotocol.async_using_multipan(hass) is False + + +async def test_change_channel( + hass: HomeAssistant, setup_zha, zigpy_app_controller: ControllerApplication +) -> None: + """Test changing the channel.""" + await setup_zha() + + with patch.object( + zigpy_app_controller, "move_network_to_channel", autospec=True + ) as mock_move_network_to_channel: + task = await silabs_multiprotocol.async_change_channel(hass, 20) + await task + + assert mock_move_network_to_channel.mock_calls == [call(20)] + + +async def test_change_channel_no_zha( + hass: HomeAssistant, zigpy_app_controller: ControllerApplication +) -> None: + """Test changing the channel with no ZHA config entries and no database.""" + with patch.object( + zigpy_app_controller, "move_network_to_channel", autospec=True + ) as mock_move_network_to_channel: + task = await silabs_multiprotocol.async_change_channel(hass, 20) + assert task is None + + assert mock_move_network_to_channel.mock_calls == [] + + +@pytest.mark.parametrize(("delay", "sleep"), [(0, 0), (5, 0), (15, 15 - 10.27)]) +async def test_change_channel_delay( + hass: HomeAssistant, + setup_zha, + zigpy_app_controller: ControllerApplication, + delay: float, + sleep: float, +) -> None: + """Test changing the channel with a delay.""" + await setup_zha() + + with patch.object( + zigpy_app_controller, "move_network_to_channel", autospec=True + ) as mock_move_network_to_channel, patch( + "homeassistant.components.zha.silabs_multiprotocol.asyncio.sleep", autospec=True + ) as mock_sleep: + task = await silabs_multiprotocol.async_change_channel(hass, 20, delay=delay) + await task + + assert mock_move_network_to_channel.mock_calls == [call(20)] + assert mock_sleep.mock_calls == [call(sleep)] From 44274e5323e34de674fba6c0eb3c6e8323acbeb4 Mon Sep 17 00:00:00 2001 From: Sebastian Heiden Date: Thu, 1 Jun 2023 18:04:00 +0200 Subject: [PATCH 765/822] Fix LaMetric Config Flow for SKY (#93483) Co-authored-by: Franck Nijhof --- .../components/lametric/config_flow.py | 6 +- tests/components/lametric/conftest.py | 12 +++- .../lametric/fixtures/device_sa5.json | 71 +++++++++++++++++++ tests/components/lametric/test_config_flow.py | 52 ++++++++++++++ 4 files changed, 137 insertions(+), 4 deletions(-) create mode 100644 tests/components/lametric/fixtures/device_sa5.json diff --git a/homeassistant/components/lametric/config_flow.py b/homeassistant/components/lametric/config_flow.py index 8e9da5851cf5..1dad190d7067 100644 --- a/homeassistant/components/lametric/config_flow.py +++ b/homeassistant/components/lametric/config_flow.py @@ -248,6 +248,10 @@ class LaMetricFlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): updates={CONF_HOST: lametric.host, CONF_API_KEY: lametric.api_key} ) + notify_sound: Sound | None = None + if device.model != "sa5": + notify_sound = Sound(sound=NotificationSound.WIN) + await lametric.notify( notification=Notification( priority=NotificationPriority.CRITICAL, @@ -255,7 +259,7 @@ class LaMetricFlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): model=Model( cycles=2, frames=[Simple(text="Connected to Home Assistant!", icon=7956)], - sound=Sound(sound=NotificationSound.WIN), + sound=notify_sound, ), ) ) diff --git a/tests/components/lametric/conftest.py b/tests/components/lametric/conftest.py index 177204b6f241..b3a9f2d86650 100644 --- a/tests/components/lametric/conftest.py +++ b/tests/components/lametric/conftest.py @@ -67,8 +67,14 @@ def mock_lametric_cloud() -> Generator[MagicMock, None, None]: @pytest.fixture -def mock_lametric() -> Generator[MagicMock, None, None]: - """Return a mocked LaMetric client.""" +def device_fixture() -> str: + """Return the device fixture for a specific device.""" + return "device" + + +@pytest.fixture +def mock_lametric(request, device_fixture: str) -> Generator[MagicMock, None, None]: + """Return a mocked LaMetric TIME client.""" with patch( "homeassistant.components.lametric.coordinator.LaMetricDevice", autospec=True ) as lametric_mock, patch( @@ -79,7 +85,7 @@ def mock_lametric() -> Generator[MagicMock, None, None]: lametric.api_key = "mock-api-key" lametric.host = "127.0.0.1" lametric.device.return_value = Device.parse_raw( - load_fixture("device.json", DOMAIN) + load_fixture(f"{device_fixture}.json", DOMAIN) ) yield lametric diff --git a/tests/components/lametric/fixtures/device_sa5.json b/tests/components/lametric/fixtures/device_sa5.json new file mode 100644 index 000000000000..47120f672efd --- /dev/null +++ b/tests/components/lametric/fixtures/device_sa5.json @@ -0,0 +1,71 @@ +{ + "audio": { + "volume": 100, + "volume_limit": { + "max": 100, + "min": 0 + }, + "volume_range": { + "max": 100, + "min": 0 + } + }, + "bluetooth": { + "active": true, + "address": "AA:BB:CC:DD:EE:FF", + "available": true, + "discoverable": true, + "low_energy": { + "active": true, + "advertising": true, + "connectable": true + }, + "name": "SKY0123", + "pairable": false + }, + "display": { + "brightness": 66, + "brightness_limit": { + "max": 100, + "min": 2 + }, + "brightness_mode": "manual", + "brightness_range": { + "max": 100, + "min": 0 + }, + "height": 8, + "on": true, + "screensaver": { + "enabled": true, + "modes": { + "screen_off": { + "enabled": false + }, + "time_based": { + "enabled": false + } + }, + "widget": "" + }, + "type": "mixed", + "width": 64 + }, + "id": "12345", + "mode": "manual", + "model": "sa5", + "name": "spyfly's LaMetric SKY", + "os_version": "3.0.13", + "serial_number": "SA52100000123TBNC", + "wifi": { + "active": true, + "mac": "AA:BB:CC:DD:EE:FF", + "available": true, + "encryption": "WPA", + "ssid": "IoT", + "ip": "127.0.0.1", + "mode": "dhcp", + "netmask": "255.255.255.0", + "strength": 58 + } +} diff --git a/tests/components/lametric/test_config_flow.py b/tests/components/lametric/test_config_flow.py index 8fd0ef061ac1..0fa3a2d98382 100644 --- a/tests/components/lametric/test_config_flow.py +++ b/tests/components/lametric/test_config_flow.py @@ -6,6 +6,9 @@ from demetriek import ( LaMetricConnectionError, LaMetricConnectionTimeoutError, LaMetricError, + Notification, + NotificationSound, + Sound, ) import pytest @@ -238,6 +241,10 @@ async def test_full_manual( assert len(mock_lametric.device.mock_calls) == 1 assert len(mock_lametric.notify.mock_calls) == 1 + + notification: Notification = mock_lametric.notify.mock_calls[0][2]["notification"] + assert notification.model.sound == Sound(sound=NotificationSound.WIN) + assert len(mock_setup_entry.mock_calls) == 1 @@ -894,3 +901,48 @@ async def test_reauth_manual( assert len(mock_lametric.device.mock_calls) == 1 assert len(mock_lametric.notify.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_setup_entry") +@pytest.mark.parametrize("device_fixture", ["device_sa5"]) +async def test_reauth_manual_sky( + hass: HomeAssistant, + mock_lametric: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reauth flow with manual entry for LaMetric Sky.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "unique_id": mock_config_entry.unique_id, + "entry_id": mock_config_entry.entry_id, + }, + data=mock_config_entry.data, + ) + + flow_id = result["flow_id"] + + await hass.config_entries.flow.async_configure( + flow_id, user_input={"next_step_id": "manual_entry"} + ) + + result2 = await hass.config_entries.flow.async_configure( + flow_id, user_input={CONF_API_KEY: "mock-api-key"} + ) + + assert result2.get("type") == FlowResultType.ABORT + assert result2.get("reason") == "reauth_successful" + assert mock_config_entry.data == { + CONF_HOST: "127.0.0.1", + CONF_API_KEY: "mock-api-key", + CONF_MAC: "AA:BB:CC:DD:EE:FF", + } + + assert len(mock_lametric.device.mock_calls) == 1 + assert len(mock_lametric.notify.mock_calls) == 1 + + notification: Notification = mock_lametric.notify.mock_calls[0][2]["notification"] + assert notification.model.sound is None From 7f6c0fdd4cb184a988413aebf006aed352181498 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Thu, 1 Jun 2023 16:18:49 +0200 Subject: [PATCH 766/822] Raise exception instead of hide in logs on zha write (#93571) Raise exception instead of hide in logs Write request that failed parsing of data would fail, yet display as successful in the gui. --- homeassistant/components/zha/core/device.py | 28 +++++++++++-------- homeassistant/components/zha/websocket_api.py | 3 ++ 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index 139acb23923d..311e876bbc06 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -740,9 +740,15 @@ class ZHADevice(LogMixin): manufacturer=None, ): """Write a value to a zigbee attribute for a cluster in this entity.""" - cluster = self.async_get_cluster(endpoint_id, cluster_id, cluster_type) - if cluster is None: - return None + try: + cluster: Cluster = self.async_get_cluster( + endpoint_id, cluster_id, cluster_type + ) + except KeyError as exc: + raise ValueError( + f"Cluster {cluster_id} not found on endpoint {endpoint_id} while" + f" writing attribute {attribute} with value {value}" + ) from exc try: response = await cluster.write_attributes( @@ -758,15 +764,13 @@ class ZHADevice(LogMixin): ) return response except zigpy.exceptions.ZigbeeException as exc: - self.debug( - "failed to set attribute: %s %s %s %s %s", - f"{ATTR_VALUE}: {value}", - f"{ATTR_ATTRIBUTE}: {attribute}", - f"{ATTR_CLUSTER_ID}: {cluster_id}", - f"{ATTR_ENDPOINT_ID}: {endpoint_id}", - exc, - ) - return None + raise HomeAssistantError( + f"Failed to set attribute: " + f"{ATTR_VALUE}: {value} " + f"{ATTR_ATTRIBUTE}: {attribute} " + f"{ATTR_CLUSTER_ID}: {cluster_id} " + f"{ATTR_ENDPOINT_ID}: {endpoint_id}" + ) from exc async def issue_cluster_command( self, diff --git a/homeassistant/components/zha/websocket_api.py b/homeassistant/components/zha/websocket_api.py index 2d4126861b40..019a5c50238a 100644 --- a/homeassistant/components/zha/websocket_api.py +++ b/homeassistant/components/zha/websocket_api.py @@ -1302,6 +1302,9 @@ def async_load_api(hass: HomeAssistant) -> None: cluster_type=cluster_type, manufacturer=manufacturer, ) + else: + raise ValueError(f"Device with IEEE {str(ieee)} not found") + _LOGGER.debug( ( "Set attribute for: %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s:" From 50bd9e9fddd9db4dcd312df466496ba8abb7d578 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 1 Jun 2023 12:31:17 -0500 Subject: [PATCH 767/822] Make RestoreStateData.async_get_instance backwards compatible (#93924) --- homeassistant/helpers/restore_state.py | 44 ++++++--- tests/helpers/test_restore_state.py | 122 ++++++++++++++++++++++++- 2 files changed, 149 insertions(+), 17 deletions(-) diff --git a/homeassistant/helpers/restore_state.py b/homeassistant/helpers/restore_state.py index e34e3c86324f..ab3b93cf3c47 100644 --- a/homeassistant/helpers/restore_state.py +++ b/homeassistant/helpers/restore_state.py @@ -16,6 +16,7 @@ import homeassistant.util.dt as dt_util from . import start from .entity import Entity from .event import async_track_time_interval +from .frame import report from .json import JSONEncoder from .storage import Store @@ -96,7 +97,9 @@ class StoredState: async def async_load(hass: HomeAssistant) -> None: """Load the restore state task.""" - hass.data[DATA_RESTORE_STATE] = await RestoreStateData.async_get_instance(hass) + restore_state = RestoreStateData(hass) + await restore_state.async_setup() + hass.data[DATA_RESTORE_STATE] = restore_state @callback @@ -108,25 +111,26 @@ def async_get(hass: HomeAssistant) -> RestoreStateData: class RestoreStateData: """Helper class for managing the helper saved data.""" - @staticmethod - async def async_get_instance(hass: HomeAssistant) -> RestoreStateData: - """Get the instance of this data helper.""" - data = RestoreStateData(hass) - await data.async_load() - - async def hass_start(hass: HomeAssistant) -> None: - """Start the restore state task.""" - data.async_setup_dump() - - start.async_at_start(hass, hass_start) - - return data - @classmethod async def async_save_persistent_states(cls, hass: HomeAssistant) -> None: """Dump states now.""" await async_get(hass).async_dump_states() + @classmethod + async def async_get_instance(cls, hass: HomeAssistant) -> RestoreStateData: + """Return the instance of this class.""" + # Nothing should actually be calling this anymore, but we'll keep it + # around for a while to avoid breaking custom components. + # + # In fact they should not be accessing this at all. + report( + "restore_state.RestoreStateData.async_get_instance is deprecated, " + "and not intended to be called by custom components; Please" + "refactor your code to use RestoreEntity instead;" + " restore_state.async_get(hass) can be used in the meantime", + ) + return async_get(hass) + def __init__(self, hass: HomeAssistant) -> None: """Initialize the restore state data class.""" self.hass: HomeAssistant = hass @@ -136,6 +140,16 @@ class RestoreStateData: self.last_states: dict[str, StoredState] = {} self.entities: dict[str, RestoreEntity] = {} + async def async_setup(self) -> None: + """Set up up the instance of this data helper.""" + await self.async_load() + + async def hass_start(hass: HomeAssistant) -> None: + """Start the restore state task.""" + self.async_setup_dump() + + start.async_at_start(self.hass, hass_start) + async def async_load(self) -> None: """Load the instance of this data helper.""" try: diff --git a/tests/helpers/test_restore_state.py b/tests/helpers/test_restore_state.py index cf6a078d1370..b5ce7afade04 100644 --- a/tests/helpers/test_restore_state.py +++ b/tests/helpers/test_restore_state.py @@ -1,12 +1,19 @@ """The tests for the Restore component.""" +from collections.abc import Coroutine from datetime import datetime, timedelta +import logging from typing import Any -from unittest.mock import patch +from unittest.mock import Mock, patch + +import pytest from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP from homeassistant.core import CoreState, HomeAssistant, State from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.reload import async_get_platform_without_config_entry from homeassistant.helpers.restore_state import ( DATA_RESTORE_STATE, STORAGE_KEY, @@ -16,9 +23,20 @@ from homeassistant.helpers.restore_state import ( async_get, async_load, ) +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util -from tests.common import async_fire_time_changed +from tests.common import ( + MockModule, + MockPlatform, + async_fire_time_changed, + mock_entity_platform, + mock_integration, +) + +_LOGGER = logging.getLogger(__name__) +DOMAIN = "test_domain" +PLATFORM = "test_platform" async def test_caching_data(hass: HomeAssistant) -> None: @@ -68,6 +86,20 @@ async def test_caching_data(hass: HomeAssistant) -> None: assert mock_write_data.called +async def test_async_get_instance_backwards_compatibility(hass: HomeAssistant) -> None: + """Test async_get_instance backwards compatibility.""" + await async_load(hass) + data = async_get(hass) + # When called from core it should raise + with pytest.raises(RuntimeError): + await RestoreStateData.async_get_instance(hass) + + # When called from a component it should not raise + # but it should report + with patch("homeassistant.helpers.restore_state.report"): + assert data is await RestoreStateData.async_get_instance(hass) + + async def test_periodic_write(hass: HomeAssistant) -> None: """Test that we write periodiclly but not after stop.""" data = async_get(hass) @@ -401,3 +433,89 @@ async def test_restoring_invalid_entity_id( state = await entity.async_get_last_state() assert state is None + + +async def test_restore_entity_end_to_end( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """Test restoring an entity end-to-end.""" + component_setup = Mock(return_value=True) + + setup_called = [] + + entity_id = "test_domain.unnamed_device" + data = async_get(hass) + now = dt_util.utcnow() + data.last_states = { + entity_id: StoredState(State(entity_id, "stored"), None, now), + } + + class MockRestoreEntity(RestoreEntity): + """Mock restore entity.""" + + def __init__(self): + """Initialize the mock entity.""" + self._state: str | None = None + + @property + def state(self): + """Return the state.""" + return self._state + + async def async_added_to_hass(self) -> Coroutine[Any, Any, None]: + """Run when entity about to be added to hass.""" + await super().async_added_to_hass() + self._state = (await self.async_get_last_state()).state + + async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, + ) -> None: + """Set up the test platform.""" + async_add_entities([MockRestoreEntity()]) + setup_called.append(True) + + mock_integration(hass, MockModule(DOMAIN, setup=component_setup)) + mock_integration(hass, MockModule(PLATFORM, dependencies=[DOMAIN])) + + mock_platform = MockPlatform(async_setup_platform=async_setup_platform) + mock_entity_platform(hass, f"{DOMAIN}.{PLATFORM}", mock_platform) + + component = EntityComponent(_LOGGER, DOMAIN, hass) + + await component.async_setup({DOMAIN: {"platform": PLATFORM, "sensors": None}}) + await hass.async_block_till_done() + assert component_setup.called + + assert f"{DOMAIN}.{PLATFORM}" in hass.config.components + assert len(setup_called) == 1 + + platform = async_get_platform_without_config_entry(hass, PLATFORM, DOMAIN) + assert platform.platform_name == PLATFORM + assert platform.domain == DOMAIN + assert hass.states.get(entity_id).state == "stored" + + await data.async_dump_states() + await hass.async_block_till_done() + + storage_data = hass_storage[STORAGE_KEY]["data"] + assert len(storage_data) == 1 + assert storage_data[0]["state"]["entity_id"] == entity_id + assert storage_data[0]["state"]["state"] == "stored" + + await platform.async_reset() + + assert hass.states.get(entity_id) is None + + # Make sure the entity still gets saved to restore state + # even though the platform has been reset since it should + # not be expired yet. + await data.async_dump_states() + await hass.async_block_till_done() + + storage_data = hass_storage[STORAGE_KEY]["data"] + assert len(storage_data) == 1 + assert storage_data[0]["state"]["entity_id"] == entity_id + assert storage_data[0]["state"]["state"] == "stored" From 358700af4e6b58000646ce618a85a7792920b320 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 1 Jun 2023 19:23:42 +0200 Subject: [PATCH 768/822] Update frontend to 20230601.1 (#93927) --- 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 bde1977b1c1d..838294f7ba5b 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==20230601.0"] + "requirements": ["home-assistant-frontend==20230601.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c8c085b761c8..17efae774983 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -25,7 +25,7 @@ ha-av==10.1.0 hass-nabucasa==0.67.1 hassil==1.0.6 home-assistant-bluetooth==1.10.0 -home-assistant-frontend==20230601.0 +home-assistant-frontend==20230601.1 home-assistant-intents==2023.5.30 httpx==0.24.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 89bf881e716f..1eadb01faa08 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -924,7 +924,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230601.0 +home-assistant-frontend==20230601.1 # homeassistant.components.conversation home-assistant-intents==2023.5.30 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0ca7cb47c34b..7ea12c24bd2c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -716,7 +716,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230601.0 +home-assistant-frontend==20230601.1 # homeassistant.components.conversation home-assistant-intents==2023.5.30 From 5ac3bb9e9bf0f992459ea205700c9f83e858389c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 1 Jun 2023 12:14:03 -0500 Subject: [PATCH 769/822] Fix onvif cameras that use basic auth with no password (#93928) --- homeassistant/components/onvif/__init__.py | 37 +++++++++++----------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/onvif/__init__.py b/homeassistant/components/onvif/__init__.py index a834a8f2df6d..ea6cd542fea7 100644 --- a/homeassistant/components/onvif/__init__.py +++ b/homeassistant/components/onvif/__init__.py @@ -1,10 +1,11 @@ """The ONVIF integration.""" import asyncio +from contextlib import suppress from http import HTTPStatus import logging from httpx import RequestError -from onvif.exceptions import ONVIFAuthError, ONVIFError, ONVIFTimeoutError +from onvif.exceptions import ONVIFError from onvif.util import is_auth_error, stringify_onvif_error from zeep.exceptions import Fault, TransportError @@ -120,31 +121,31 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await hass.config_entries.async_unload_platforms(entry, device.platforms) -async def _get_snapshot_auth(device): +async def _get_snapshot_auth(device: ONVIFDevice) -> str | None: """Determine auth type for snapshots.""" - if not device.capabilities.snapshot or not (device.username and device.password): - return HTTP_DIGEST_AUTHENTICATION + if not device.capabilities.snapshot: + return None - try: - snapshot = await device.device.get_snapshot(device.profiles[0].token) + for basic_auth in (False, True): + method = HTTP_BASIC_AUTHENTICATION if basic_auth else HTTP_DIGEST_AUTHENTICATION + with suppress(ONVIFError): + if await device.device.get_snapshot(device.profiles[0].token, basic_auth): + return method - if snapshot: - return HTTP_DIGEST_AUTHENTICATION - return HTTP_BASIC_AUTHENTICATION - except (ONVIFAuthError, ONVIFTimeoutError): - return HTTP_BASIC_AUTHENTICATION - except ONVIFError: - return HTTP_DIGEST_AUTHENTICATION + return None -async def async_populate_snapshot_auth(hass, device, entry): +async def async_populate_snapshot_auth( + hass: HomeAssistant, device: ONVIFDevice, entry: ConfigEntry +) -> None: """Check if digest auth for snapshots is possible.""" - auth = await _get_snapshot_auth(device) - new_data = {**entry.data, CONF_SNAPSHOT_AUTH: auth} - hass.config_entries.async_update_entry(entry, data=new_data) + if auth := await _get_snapshot_auth(device): + hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_SNAPSHOT_AUTH: auth} + ) -async def async_populate_options(hass, entry): +async def async_populate_options(hass: HomeAssistant, entry: ConfigEntry) -> None: """Populate default options for device.""" options = { CONF_EXTRA_ARGUMENTS: DEFAULT_ARGUMENTS, From 4e19843152c3e12d6a9534ae2c0b4cfad8cbd449 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 1 Jun 2023 12:15:16 -0500 Subject: [PATCH 770/822] Bump python-onvif-zeep to 3.1.9 (#93930) --- homeassistant/components/onvif/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/onvif/manifest.json b/homeassistant/components/onvif/manifest.json index fd00d28b8324..e92e80a9a68a 100644 --- a/homeassistant/components/onvif/manifest.json +++ b/homeassistant/components/onvif/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/onvif", "iot_class": "local_push", "loggers": ["onvif", "wsdiscovery", "zeep"], - "requirements": ["onvif-zeep-async==3.1.8", "WSDiscovery==2.0.0"] + "requirements": ["onvif-zeep-async==3.1.9", "WSDiscovery==2.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1eadb01faa08..ef0d50c94b03 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1271,7 +1271,7 @@ ondilo==0.2.0 onkyo-eiscp==1.2.7 # homeassistant.components.onvif -onvif-zeep-async==3.1.8 +onvif-zeep-async==3.1.9 # homeassistant.components.opengarage open-garage==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7ea12c24bd2c..104483b6f1b1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -961,7 +961,7 @@ omnilogic==0.4.5 ondilo==0.2.0 # homeassistant.components.onvif -onvif-zeep-async==3.1.8 +onvif-zeep-async==3.1.9 # homeassistant.components.opengarage open-garage==0.2.0 From 3317ea7d95892d9b38004bf86b07e5ddf98acb70 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 1 Jun 2023 12:20:32 -0500 Subject: [PATCH 771/822] Bump pyunifiprotect to 4.9.1 (#93931) --- 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 fcb30cdba5fb..a2bb76c92b75 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.9.0", "unifi-discovery==1.1.7"], + "requirements": ["pyunifiprotect==4.9.1", "unifi-discovery==1.1.7"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index ef0d50c94b03..befb12ec5280 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2171,7 +2171,7 @@ pytrafikverket==0.3.3 pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.9.0 +pyunifiprotect==4.9.1 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 104483b6f1b1..e7aac0f83141 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1579,7 +1579,7 @@ pytrafikverket==0.3.3 pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.9.0 +pyunifiprotect==4.9.1 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 From a98094eda62a06a4672a8c5e23836a66052ea56c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 1 Jun 2023 15:07:11 -0400 Subject: [PATCH 772/822] Bumped version to 2023.6.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 37eb0d02c606..e6dbcc96d0d1 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -8,7 +8,7 @@ from .backports.enum import StrEnum APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 MINOR_VERSION: Final = 6 -PATCH_VERSION: Final = "0b1" +PATCH_VERSION: Final = "0b2" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 10, 0) diff --git a/pyproject.toml b/pyproject.toml index 9d075644101d..04197ba7b1d0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.6.0b1" +version = "2023.6.0b2" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From e58ea00ce6d5be4aa9100a3d9b4fd0db303036e1 Mon Sep 17 00:00:00 2001 From: Tudor Sandu Date: Fri, 2 Jun 2023 03:10:57 +0300 Subject: [PATCH 773/822] Fix states not being translated in voice assistants (#93572) Fix states not being translated --- homeassistant/components/conversation/default_agent.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 5cb4487de657..44b13522412a 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -278,13 +278,13 @@ class DefaultAgent(AbstractConversationAgent): all_states = matched + unmatched domains = {state.domain for state in all_states} translations = await translation.async_get_translations( - self.hass, language, "state", domains + self.hass, language, "entity_component", domains ) # Use translated state names for state in all_states: device_class = state.attributes.get("device_class", "_") - key = f"component.{state.domain}.state.{device_class}.{state.state}" + key = f"component.{state.domain}.entity_component.{device_class}.state.{state.state}" state.state = translations.get(key, state.state) # Get first matched or unmatched state. From 964af88c21e538354f0ee9c9d6eca2b0fe7c81ff Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Fri, 2 Jun 2023 13:44:36 +0100 Subject: [PATCH 774/822] Make Riemann sum sensors restore last valid state (#93674) * keep last valid state * keep last valid state * typo * increase coverage * better error handling * debug messages * increase coverage * remove random log * don't expose last_valid_state as an attribute --- .../components/integration/sensor.py | 119 ++++++++++++++++-- tests/components/integration/test_sensor.py | 96 +++++++++++++- 2 files changed, 204 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index 7e60f2c509ca..b28b426d3af3 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -1,16 +1,19 @@ """Numeric integration of data coming from a source sensor over time.""" from __future__ import annotations -from decimal import Decimal, DecimalException +from dataclasses import dataclass +from decimal import Decimal, DecimalException, InvalidOperation import logging -from typing import Final +from typing import Any, Final +from typing_extensions import Self import voluptuous as vol from homeassistant.components.sensor import ( PLATFORM_SCHEMA, + RestoreSensor, SensorDeviceClass, - SensorEntity, + SensorExtraStoredData, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry @@ -28,7 +31,6 @@ from homeassistant.core import Event, HomeAssistant, State, callback from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event -from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ( @@ -79,6 +81,53 @@ PLATFORM_SCHEMA = vol.All( ) +@dataclass +class IntegrationSensorExtraStoredData(SensorExtraStoredData): + """Object to hold extra stored data.""" + + source_entity: str | None + last_valid_state: Decimal | None + + def as_dict(self) -> dict[str, Any]: + """Return a dict representation of the utility sensor data.""" + data = super().as_dict() + data["source_entity"] = self.source_entity + data["last_valid_state"] = ( + str(self.last_valid_state) if self.last_valid_state else None + ) + return data + + @classmethod + def from_dict(cls, restored: dict[str, Any]) -> Self | None: + """Initialize a stored sensor state from a dict.""" + extra = SensorExtraStoredData.from_dict(restored) + if extra is None: + return None + + source_entity = restored.get(ATTR_SOURCE_ID) + + try: + last_valid_state = ( + Decimal(str(restored.get("last_valid_state"))) + if restored.get("last_valid_state") + else None + ) + except InvalidOperation: + # last_period is corrupted + _LOGGER.error("Could not use last_valid_state") + return None + + if last_valid_state is None: + return None + + return cls( + extra.native_value, + extra.native_unit_of_measurement, + source_entity, + last_valid_state, + ) + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -129,7 +178,7 @@ async def async_setup_platform( # pylint: disable-next=hass-invalid-inheritance # needs fixing -class IntegrationSensor(RestoreEntity, SensorEntity): +class IntegrationSensor(RestoreSensor): """Representation of an integration sensor.""" _attr_state_class = SensorStateClass.TOTAL @@ -160,7 +209,8 @@ class IntegrationSensor(RestoreEntity, SensorEntity): self._unit_time = UNIT_TIME[unit_time] self._unit_time_str = unit_time self._attr_icon = "mdi:chart-histogram" - self._attr_extra_state_attributes = {ATTR_SOURCE_ID: source_entity} + self._source_entity: str = source_entity + self._last_valid_state: Decimal | None = None def _unit(self, source_unit: str) -> str: """Derive unit from the source sensor, SI prefix and time unit.""" @@ -175,10 +225,28 @@ class IntegrationSensor(RestoreEntity, SensorEntity): 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()) is not None: - if state.state == STATE_UNAVAILABLE: - self._attr_available = False - elif state.state != STATE_UNKNOWN: + + if (last_sensor_data := await self.async_get_last_sensor_data()) is not None: + self._state = ( + Decimal(str(last_sensor_data.native_value)) + if last_sensor_data.native_value + else last_sensor_data.last_valid_state + ) + self._attr_native_value = last_sensor_data.native_value + self._unit_of_measurement = last_sensor_data.native_unit_of_measurement + self._last_valid_state = last_sensor_data.last_valid_state + + _LOGGER.debug( + "Restored state %s and last_valid_state %s", + self._state, + self._last_valid_state, + ) + elif (state := await self.async_get_last_state()) is not None: + # legacy to be removed on 2023.10 (we are keeping this to avoid losing data during the transition) + if state.state in [STATE_UNAVAILABLE, STATE_UNKNOWN]: + if state.state == STATE_UNAVAILABLE: + self._attr_available = False + else: try: self._state = Decimal(state.state) except (DecimalException, ValueError) as err: @@ -295,6 +363,7 @@ class IntegrationSensor(RestoreEntity, SensorEntity): self._state += integral else: self._state = integral + self._last_valid_state = self._state self.async_write_ha_state() self.async_on_remove( @@ -314,3 +383,33 @@ class IntegrationSensor(RestoreEntity, SensorEntity): def native_unit_of_measurement(self) -> str | None: """Return the unit the value is expressed in.""" return self._unit_of_measurement + + @property + def extra_state_attributes(self) -> dict[str, str] | None: + """Return the state attributes of the sensor.""" + state_attr = { + ATTR_SOURCE_ID: self._source_entity, + } + + return state_attr + + @property + def extra_restore_state_data(self) -> IntegrationSensorExtraStoredData: + """Return sensor specific state data to be restored.""" + return IntegrationSensorExtraStoredData( + self.native_value, + self.native_unit_of_measurement, + self._source_entity, + self._last_valid_state, + ) + + async def async_get_last_sensor_data( + self, + ) -> IntegrationSensorExtraStoredData | None: + """Restore Utility Meter Sensor Extra Stored Data.""" + if (restored_last_extra_data := await self.async_get_last_extra_data()) is None: + return None + + return IntegrationSensorExtraStoredData.from_dict( + restored_last_extra_data.as_dict() + ) diff --git a/tests/components/integration/test_sensor.py b/tests/components/integration/test_sensor.py index 515ae990debc..355d13c84d6c 100644 --- a/tests/components/integration/test_sensor.py +++ b/tests/components/integration/test_sensor.py @@ -19,7 +19,7 @@ from homeassistant.core import HomeAssistant, State from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from tests.common import mock_restore_cache +from tests.common import mock_restore_cache, mock_restore_cache_with_extra_data @pytest.mark.parametrize("method", ["trapezoidal", "left", "right"]) @@ -163,6 +163,100 @@ async def test_restore_state(hass: HomeAssistant) -> None: assert state.state == "100.00" assert state.attributes.get("unit_of_measurement") == UnitOfEnergy.KILO_WATT_HOUR assert state.attributes.get("device_class") == SensorDeviceClass.ENERGY + assert state.attributes.get("last_good_state") is None + + +async def test_restore_unavailable_state(hass: HomeAssistant) -> None: + """Test integration sensor state is restored correctly.""" + mock_restore_cache_with_extra_data( + hass, + [ + ( + State( + "sensor.integration", + STATE_UNAVAILABLE, + { + "device_class": SensorDeviceClass.ENERGY, + "unit_of_measurement": UnitOfEnergy.KILO_WATT_HOUR, + }, + ), + { + "native_value": None, + "native_unit_of_measurement": "kWh", + "source_entity": "sensor.power", + "last_valid_state": "100.00", + }, + ), + ], + ) + config = { + "sensor": { + "platform": "integration", + "name": "integration", + "source": "sensor.power", + "round": 2, + } + } + + assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + + state = hass.states.get("sensor.integration") + assert state + assert state.state == "100.00" + + +@pytest.mark.parametrize( + "extra_attributes", + [ + { + "native_unit_of_measurement": "kWh", + "source_entity": "sensor.power", + "last_valid_state": "100.00", + }, + { + "native_value": None, + "native_unit_of_measurement": "kWh", + "source_entity": "sensor.power", + "last_valid_state": "None", + }, + ], +) +async def test_restore_unavailable_state_failed( + hass: HomeAssistant, extra_attributes +) -> None: + """Test integration sensor state is restored correctly.""" + mock_restore_cache_with_extra_data( + hass, + [ + ( + State( + "sensor.integration", + STATE_UNAVAILABLE, + { + "device_class": SensorDeviceClass.ENERGY, + "unit_of_measurement": UnitOfEnergy.KILO_WATT_HOUR, + }, + ), + extra_attributes, + ), + ], + ) + config = { + "sensor": { + "platform": "integration", + "name": "integration", + "source": "sensor.power", + "round": 2, + } + } + + assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + + state = hass.states.get("sensor.integration") + assert state + assert state.state == STATE_UNAVAILABLE async def test_restore_state_failed(hass: HomeAssistant) -> None: From d9149407d8e5887988a22909eb6b74a5b025ffc3 Mon Sep 17 00:00:00 2001 From: Chris Talkington Date: Thu, 1 Jun 2023 22:24:53 -0500 Subject: [PATCH 775/822] Update pyipp to 0.13.0 (#93886) --- homeassistant/components/ipp/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ipp/manifest.json b/homeassistant/components/ipp/manifest.json index 59f8c32c2107..e93f98327224 100644 --- a/homeassistant/components/ipp/manifest.json +++ b/homeassistant/components/ipp/manifest.json @@ -8,6 +8,6 @@ "iot_class": "local_polling", "loggers": ["deepmerge", "pyipp"], "quality_scale": "platinum", - "requirements": ["pyipp==0.12.1"], + "requirements": ["pyipp==0.13.0"], "zeroconf": ["_ipps._tcp.local.", "_ipp._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index befb12ec5280..be7a22e02b9e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1708,7 +1708,7 @@ pyintesishome==1.8.0 pyipma==3.0.6 # homeassistant.components.ipp -pyipp==0.12.1 +pyipp==0.13.0 # homeassistant.components.iqvia pyiqvia==2022.04.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e7aac0f83141..cf5d78235a27 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1254,7 +1254,7 @@ pyinsteon==1.4.2 pyipma==3.0.6 # homeassistant.components.ipp -pyipp==0.12.1 +pyipp==0.13.0 # homeassistant.components.iqvia pyiqvia==2022.04.0 From 3d2ad2fd85706896ca8198eedfa86f8d6cbad455 Mon Sep 17 00:00:00 2001 From: automaton82 Date: Thu, 1 Jun 2023 16:23:26 -0400 Subject: [PATCH 776/822] Update netdata to 1.1.0, set longer timeout (#93937) --- homeassistant/components/netdata/manifest.json | 2 +- homeassistant/components/netdata/sensor.py | 2 +- requirements_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/netdata/manifest.json b/homeassistant/components/netdata/manifest.json index 2d7604765c43..99410ce033d3 100644 --- a/homeassistant/components/netdata/manifest.json +++ b/homeassistant/components/netdata/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/netdata", "iot_class": "local_polling", "loggers": ["netdata"], - "requirements": ["netdata==1.0.1"] + "requirements": ["netdata==1.1.0"] } diff --git a/homeassistant/components/netdata/sensor.py b/homeassistant/components/netdata/sensor.py index 6606604ac90c..1ab7a48e1b32 100644 --- a/homeassistant/components/netdata/sensor.py +++ b/homeassistant/components/netdata/sensor.py @@ -66,7 +66,7 @@ async def async_setup_platform( port = config[CONF_PORT] resources = config[CONF_RESOURCES] - netdata = NetdataData(Netdata(host, port=port)) + netdata = NetdataData(Netdata(host, port=port, timeout=20.0)) await netdata.async_update() if netdata.api.metrics is None: diff --git a/requirements_all.txt b/requirements_all.txt index be7a22e02b9e..38b328ab848f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1182,7 +1182,7 @@ ndms2_client==0.1.2 nessclient==0.10.0 # homeassistant.components.netdata -netdata==1.0.1 +netdata==1.1.0 # homeassistant.components.discovery netdisco==3.0.0 From cc02d1dfc4c4b8a850d014cd705597a06b74ef64 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 1 Jun 2023 18:54:25 -0500 Subject: [PATCH 777/822] Fix august aiohttp session being closed out from under it (#93942) * Fix august aiohttp session being closed out from under it fixes #93941 * Fix august aiohttp session being closed out from under it fixes #93941 * Fix august aiohttp session being closed out from under it fixes #93941 --- homeassistant/components/august/__init__.py | 9 ++++-- .../components/august/config_flow.py | 29 +++++++++++++++++-- homeassistant/components/august/gateway.py | 10 ++----- tests/components/august/test_gateway.py | 2 +- 4 files changed, 36 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/august/__init__.py b/homeassistant/components/august/__init__.py index 8be7d8dd2d19..8738b58dab9d 100644 --- a/homeassistant/components/august/__init__.py +++ b/homeassistant/components/august/__init__.py @@ -23,7 +23,7 @@ from homeassistant.exceptions import ( ConfigEntryNotReady, HomeAssistantError, ) -from homeassistant.helpers import device_registry as dr, discovery_flow +from homeassistant.helpers import aiohttp_client, device_registry as dr, discovery_flow from .activity import ActivityStream from .const import CONF_BRAND, DOMAIN, MIN_TIME_BETWEEN_DETAIL_UPDATES, PLATFORMS @@ -44,8 +44,11 @@ YALEXS_BLE_DOMAIN = "yalexs_ble" async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up August from a config entry.""" - - august_gateway = AugustGateway(hass) + # Create an aiohttp session instead of using the default one since the + # default one is likely to trigger august's WAF if another integration + # is also using Cloudflare + session = aiohttp_client.async_create_clientsession(hass) + august_gateway = AugustGateway(hass, session) try: await august_gateway.async_setup(entry.data) diff --git a/homeassistant/components/august/config_flow.py b/homeassistant/components/august/config_flow.py index 58f1c2fc9764..670d16084210 100644 --- a/homeassistant/components/august/config_flow.py +++ b/homeassistant/components/august/config_flow.py @@ -4,13 +4,16 @@ from dataclasses import dataclass import logging from typing import Any +import aiohttp import voluptuous as vol from yalexs.authenticator import ValidationResult from yalexs.const import BRANDS, DEFAULT_BRAND from homeassistant import config_entries from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import aiohttp_client from .const import ( CONF_ACCESS_TOKEN_CACHE_FILE, @@ -80,6 +83,7 @@ class AugustConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self): """Store an AugustGateway().""" self._august_gateway: AugustGateway | None = None + self._aiohttp_session: aiohttp.ClientSession | None = None self._user_auth_details: dict[str, Any] = {} self._needs_reset = True self._mode = None @@ -87,7 +91,6 @@ class AugustConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_user(self, user_input=None): """Handle the initial step.""" - self._august_gateway = AugustGateway(self.hass) return await self.async_step_user_validate() async def async_step_user_validate(self, user_input=None): @@ -151,12 +154,30 @@ class AugustConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): }, ) + @callback + def _async_get_gateway(self) -> AugustGateway: + """Set up the gateway.""" + if self._august_gateway is not None: + return self._august_gateway + # Create an aiohttp session instead of using the default one since the + # default one is likely to trigger august's WAF if another integration + # is also using Cloudflare + self._aiohttp_session = aiohttp_client.async_create_clientsession(self.hass) + self._august_gateway = AugustGateway(self.hass, self._aiohttp_session) + return self._august_gateway + + @callback + def _async_shutdown_gateway(self) -> None: + """Shutdown the gateway.""" + if self._aiohttp_session is not None: + self._aiohttp_session.detach() + self._august_gateway = None + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle configuration by re-auth.""" self._user_auth_details = dict(entry_data) self._mode = "reauth" self._needs_reset = True - self._august_gateway = AugustGateway(self.hass) return await self.async_step_reauth_validate() async def async_step_reauth_validate(self, user_input=None): @@ -206,7 +227,7 @@ class AugustConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def _async_auth_or_validate(self) -> ValidateResult: """Authenticate or validate.""" user_auth_details = self._user_auth_details - gateway = self._august_gateway + gateway = self._async_get_gateway() assert gateway is not None await self._async_reset_access_token_cache_if_needed( gateway, @@ -239,6 +260,8 @@ class AugustConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def _async_update_or_create_entry(self, info: dict[str, Any]) -> FlowResult: """Update existing entry or create a new one.""" + self._async_shutdown_gateway() + existing_entry = await self.async_set_unique_id( self._user_auth_details[CONF_USERNAME] ) diff --git a/homeassistant/components/august/gateway.py b/homeassistant/components/august/gateway.py index 9dcf96f057af..badff721d109 100644 --- a/homeassistant/components/august/gateway.py +++ b/homeassistant/components/august/gateway.py @@ -7,7 +7,7 @@ import logging import os from typing import Any -from aiohttp import ClientError, ClientResponseError +from aiohttp import ClientError, ClientResponseError, ClientSession from yalexs.api_async import ApiAsync from yalexs.authenticator_async import AuthenticationState, AuthenticatorAsync from yalexs.authenticator_common import Authentication @@ -16,7 +16,6 @@ from yalexs.exceptions import AugustApiAIOHTTPError from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import aiohttp_client from .const import ( CONF_ACCESS_TOKEN_CACHE_FILE, @@ -35,12 +34,9 @@ _LOGGER = logging.getLogger(__name__) class AugustGateway: """Handle the connection to August.""" - def __init__(self, hass: HomeAssistant) -> None: + def __init__(self, hass: HomeAssistant, aiohttp_session: ClientSession) -> None: """Init the connection.""" - # Create an aiohttp session instead of using the default one since the - # default one is likely to trigger august's WAF if another integration - # is also using Cloudflare - self._aiohttp_session = aiohttp_client.async_create_clientsession(hass) + self._aiohttp_session = aiohttp_session self._token_refresh_lock = asyncio.Lock() self._access_token_cache_file: str | None = None self._hass: HomeAssistant = hass diff --git a/tests/components/august/test_gateway.py b/tests/components/august/test_gateway.py index d0e18c0bed46..2a364304c4b3 100644 --- a/tests/components/august/test_gateway.py +++ b/tests/components/august/test_gateway.py @@ -35,7 +35,7 @@ async def _patched_refresh_access_token( "original_token", 1234, AuthenticationState.AUTHENTICATED ) ) - august_gateway = AugustGateway(hass) + august_gateway = AugustGateway(hass, MagicMock()) mocked_config = _mock_get_config() await august_gateway.async_setup(mocked_config[DOMAIN]) await august_gateway.async_authenticate() From 9dd3e6cab884ca5399c24bfc54381c02f8055d37 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 1 Jun 2023 18:54:44 -0500 Subject: [PATCH 778/822] Bump aiohomekit to 2.6.4 (#93943) changelog: https://github.com/Jc2k/aiohomekit/compare/2.6.3...2.6.4 mostly additional logging to help track down #93891 --- 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 9db26d4c8e07..89261df8751a 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==2.6.3"], + "requirements": ["aiohomekit==2.6.4"], "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 38b328ab848f..f3b05058c8ff 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -177,7 +177,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==2.6.3 +aiohomekit==2.6.4 # homeassistant.components.emulated_hue # homeassistant.components.http diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cf5d78235a27..bd35ab613414 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -164,7 +164,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==2.6.3 +aiohomekit==2.6.4 # homeassistant.components.emulated_hue # homeassistant.components.http From 3d4ba15a95ee3d9e7498a659fcb531412dddcfaa Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Fri, 2 Jun 2023 06:09:53 -0400 Subject: [PATCH 779/822] Make Z-Wave device IBT4ZWAVE discoverable as a cover (#93946) * Make Z-Wave device IBT4ZWAVE discoverable as a cover * Test device class --- homeassistant/components/zwave_js/cover.py | 4 +- .../components/zwave_js/discovery.py | 10 + tests/components/zwave_js/conftest.py | 16 +- .../fixtures/cover_nice_ibt4zwave_state.json | 1410 +++++++++++++++++ tests/components/zwave_js/test_cover.py | 64 + 5 files changed, 1502 insertions(+), 2 deletions(-) create mode 100644 tests/components/zwave_js/fixtures/cover_nice_ibt4zwave_state.json diff --git a/homeassistant/components/zwave_js/cover.py b/homeassistant/components/zwave_js/cover.py index 4b3113af2021..9a8cb203c055 100644 --- a/homeassistant/components/zwave_js/cover.py +++ b/homeassistant/components/zwave_js/cover.py @@ -305,8 +305,10 @@ class ZWaveMultilevelSwitchCover(CoverPositionMixin): self._attr_device_class = CoverDeviceClass.WINDOW if self.info.platform_hint and self.info.platform_hint.startswith("shutter"): self._attr_device_class = CoverDeviceClass.SHUTTER - if self.info.platform_hint and self.info.platform_hint.startswith("blind"): + elif self.info.platform_hint and self.info.platform_hint.startswith("blind"): self._attr_device_class = CoverDeviceClass.BLIND + elif self.info.platform_hint and self.info.platform_hint.startswith("gate"): + self._attr_device_class = CoverDeviceClass.GATE class ZWaveTiltCover(ZWaveMultilevelSwitchCover, CoverTiltMixin): diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 62fc665c72ea..c6aa14bceb68 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -374,6 +374,16 @@ DISCOVERY_SCHEMAS = [ ) ], ), + # Fibaro Nice BiDi-ZWave (IBT4ZWAVE) + ZWaveDiscoverySchema( + platform=Platform.COVER, + hint="gate", + manufacturer_id={0x0441}, + product_id={0x1000}, + product_type={0x2400}, + primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, + required_values=[SWITCH_MULTILEVEL_TARGET_VALUE_SCHEMA], + ), # Qubino flush shutter ZWaveDiscoverySchema( platform=Platform.COVER, diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index 860e5742c808..1751bd0c0ca7 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -630,6 +630,12 @@ def energy_production_state_fixture(): return json.loads(load_fixture("zwave_js/energy_production_state.json")) +@pytest.fixture(name="nice_ibt4zwave_state", scope="session") +def nice_ibt4zwave_state_fixture(): + """Load a Nice IBT4ZWAVE cover node state fixture data.""" + return json.loads(load_fixture("zwave_js/cover_nice_ibt4zwave_state.json")) + + # model fixtures @@ -1200,8 +1206,16 @@ def indicator_test_fixture(client, indicator_test_state): @pytest.fixture(name="energy_production") -def energy_prodution_fixture(client, energy_production_state): +def energy_production_fixture(client, energy_production_state): """Mock a mock node with Energy Production CC.""" node = Node(client, copy.deepcopy(energy_production_state)) client.driver.controller.nodes[node.node_id] = node return node + + +@pytest.fixture(name="nice_ibt4zwave") +def nice_ibt4zwave_fixture(client, nice_ibt4zwave_state): + """Mock a Nice IBT4ZWAVE cover node.""" + node = Node(client, copy.deepcopy(nice_ibt4zwave_state)) + client.driver.controller.nodes[node.node_id] = node + return node diff --git a/tests/components/zwave_js/fixtures/cover_nice_ibt4zwave_state.json b/tests/components/zwave_js/fixtures/cover_nice_ibt4zwave_state.json new file mode 100644 index 000000000000..eab42c321fe1 --- /dev/null +++ b/tests/components/zwave_js/fixtures/cover_nice_ibt4zwave_state.json @@ -0,0 +1,1410 @@ +{ + "nodeId": 72, + "index": 0, + "installerIcon": 7680, + "userIcon": 7680, + "status": 4, + "ready": true, + "isListening": true, + "isRouting": true, + "isSecure": false, + "manufacturerId": 1089, + "productId": 4096, + "productType": 9216, + "firmwareVersion": "7.0", + "zwavePlusVersion": 2, + "name": "Portail", + "location": "**REDACTED**", + "deviceConfig": { + "filename": "/data/db/devices/0x0441/ibt4zwave.json", + "isEmbedded": true, + "manufacturer": "NICE Spa", + "manufacturerId": 1089, + "label": "IBT4ZWAVE", + "description": "BusT4-Z-Wave interface", + "devices": [ + { + "productType": 9216, + "productId": 4096 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "paramInformation": { + "_map": {} + }, + "metadata": { + "inclusion": "Install the external antenna before powering the device and adding to the Z-Wave network for the device to automatically detect and enable it (use only antennas and cables compliant with technical specification).\n\n01. Set the Z-Wave gateway into adding mode (see the Z-Wave gateway\u2019s manual)\n02. On the IBT4ZWAVE press and release the S1 button 3 times x3 S1\n03. LEDs on the IBT4ZW AVE will start slow flashing alter nately\n04. If you are adding in Security S2 Authenticated, input the underlined part\nof the DSK (label on the box) DSK: XXXXX-XXXXX-XXXXX-XXXXX XXXXX-XXXXX-XXXXX-XXXXX\n05. When the adding process ends, the LEDs on the IBT4ZWAVE will show adding and antenna status (Table 1 in manual)", + "exclusion": "01. Set the Z-Wave gateway into remove mode (see the Z-Wave gateway\u2019s manual)\n02. On the IBT4ZWAVE press and release the S1 button 3 times x3 S1\n03. LEDs on the IBT4ZW AVE will start slow flashing alternately\n04. Wait for the removing process to end", + "reset": "01. Press and hold the S1 button\n03. Wait 3 seconds\n04. LEDs will show adding and antenna status (Table 1 in manual) for 3 seconds\n05. LEDs will turn off for 3 seconds\n06. LEDs will show selected antenna (Table 2 in manual) for 3 seconds\n07. When both LEDs light up simultaneously, release the button\n08. Press and release the S1 button\n09. Both LEDs will flash once at the end of the procedure", + "manual": "https://products.z-wavealliance.org/ProductManual/File?folder=&filename=product_documents/3837/IBT4ZWAVE-T-v0.7.pdf" + } + }, + "label": "IBT4ZWAVE", + "interviewAttempts": 2, + "endpoints": [ + { + "nodeId": 72, + "index": 0, + "installerIcon": 7680, + "userIcon": 7680, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 0, + "label": "Unused" + }, + "mandatorySupportedCCs": [32, 38], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "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": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": false + }, + { + "id": 90, + "name": "Device Reset Locally", + "version": 1, + "isSecure": false + }, + { + "id": 122, + "name": "Firmware Update Meta Data", + "version": 5, + "isSecure": false + }, + { + "id": 135, + "name": "Indicator", + "version": 3, + "isSecure": false + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 2, + "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": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 85, + "name": "Transport Service", + "version": 2, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 3, + "isSecure": false + }, + { + "id": 113, + "name": "Notification", + "version": 8, + "isSecure": false + }, + { + "id": 117, + "name": "Protection", + "version": 2, + "isSecure": false + }, + { + "id": 112, + "name": "Configuration", + "version": 4, + "isSecure": false + } + ] + } + ], + "values": [ + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 99 + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 4, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration" + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Current value", + "min": 0, + "max": 99 + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Open", + "propertyName": "Open", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Perform a level change (Open)", + "ccSpecific": { + "switchType": 3 + }, + "valueChangeOptions": ["transitionDuration"] + }, + "value": false, + "nodeId": 72 + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Close", + "propertyName": "Close", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Perform a level change (Close)", + "ccSpecific": { + "switchType": 3 + }, + "valueChangeOptions": ["transitionDuration"] + }, + "value": true, + "nodeId": 72 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 30, + "propertyKey": 4278190080, + "propertyName": "Alarm Configuration - 1st Slot Notification Type", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Alarm Configuration - 1st Slot Notification Type", + "default": 0, + "min": 0, + "max": 22, + "states": { + "0": "Disabled", + "1": "Smoke Alarm", + "2": "CO Alarm", + "3": "CO2 Alarm", + "4": "Heat Alarm", + "5": "Water Alarm", + "6": "Access Control", + "7": "Home Security", + "8": "Power Management", + "9": "System", + "10": "Emergency Alarm", + "11": "Clock", + "12": "Appliance", + "13": "Home Health", + "14": "Siren", + "15": "Water Valve", + "16": "Weather Alarm", + "17": "Irrigation", + "18": "Gas Alarm", + "19": "Pest Control", + "20": "Light Sensor", + "21": "Water Quality Monitoring", + "22": "Home Monitoring" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 30, + "propertyKey": 16711680, + "propertyName": "Alarm Configuration - 1st Slot Notification Event", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Alarm Configuration - 1st Slot Notification Event", + "default": 0, + "min": 0, + "max": 255, + "states": { + "0": "Disabled", + "255": "Any Notification," + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 30, + "propertyKey": 65280, + "propertyName": "Alarm Configuration - 1st Slot Notification Event Parameter", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Alarm Configuration - 1st Slot Notification Event Parameter", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 30, + "propertyKey": 255, + "propertyName": "Alarm Configuration - 1st Slot Action", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Alarm Configuration - 1st Slot Action", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "No Action", + "1": "Open", + "2": "Close" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 31, + "propertyKey": 4278190080, + "propertyName": "Alarm Configuration - 2nd Slot Notification Type", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Alarm Configuration - 2nd Slot Notification Type", + "default": 5, + "min": 0, + "max": 22, + "states": { + "0": "Disabled", + "1": "Smoke Alarm", + "2": "CO Alarm", + "3": "CO2 Alarm", + "4": "Heat Alarm", + "5": "Water Alarm", + "6": "Access Control", + "7": "Home Security", + "8": "Power Management", + "9": "System", + "10": "Emergency Alarm", + "11": "Clock", + "12": "Appliance", + "13": "Home Health", + "14": "Siren", + "15": "Water Valve", + "16": "Weather Alarm", + "17": "Irrigation", + "18": "Gas Alarm", + "19": "Pest Control", + "20": "Light Sensor", + "21": "Water Quality Monitoring", + "22": "Home Monitoring" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 5 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 31, + "propertyKey": 16711680, + "propertyName": "Alarm Configuration - 2nd Slot Notification Event", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Alarm Configuration - 2nd Slot Notification Event", + "default": 255, + "min": 0, + "max": 255, + "states": { + "0": "Disabled", + "255": "Any Notification," + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 31, + "propertyKey": 65280, + "propertyName": "Alarm Configuration - 2nd Slot Notification Event Parameter", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Alarm Configuration - 2nd Slot Notification Event Parameter", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 31, + "propertyKey": 255, + "propertyName": "Alarm Configuration - 2nd Slot Action", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Alarm Configuration - 2nd Slot Action", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "No Action", + "1": "Open", + "2": "Close" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 32, + "propertyKey": 4278190080, + "propertyName": "Alarm Configuration - 3rd Slot Notification Type", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Alarm Configuration - 3rd Slot Notification Type", + "default": 1, + "min": 0, + "max": 22, + "states": { + "0": "Disabled", + "1": "Smoke Alarm", + "2": "CO Alarm", + "3": "CO2 Alarm", + "4": "Heat Alarm", + "5": "Water Alarm", + "6": "Access Control", + "7": "Home Security", + "8": "Power Management", + "9": "System", + "10": "Emergency Alarm", + "11": "Clock", + "12": "Appliance", + "13": "Home Health", + "14": "Siren", + "15": "Water Valve", + "16": "Weather Alarm", + "17": "Irrigation", + "18": "Gas Alarm", + "19": "Pest Control", + "20": "Light Sensor", + "21": "Water Quality Monitoring", + "22": "Home Monitoring" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 32, + "propertyKey": 16711680, + "propertyName": "Alarm Configuration - 3rd Slot Notification Event", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Alarm Configuration - 3rd Slot Notification Event", + "default": 255, + "min": 0, + "max": 255, + "states": { + "0": "Disabled", + "255": "Any Notification," + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 32, + "propertyKey": 65280, + "propertyName": "Alarm Configuration - 3rd Slot Notification Event Parameter", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Alarm Configuration - 3rd Slot Notification Event Parameter", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 32, + "propertyKey": 255, + "propertyName": "Alarm Configuration - 3rd Slot Action", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Alarm Configuration - 3rd Slot Action", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "No Action", + "1": "Open", + "2": "Close" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 33, + "propertyKey": 4278190080, + "propertyName": "Alarm Configuration - 4th Slot Notification Type", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Alarm Configuration - 4th Slot Notification Type", + "default": 2, + "min": 0, + "max": 22, + "states": { + "0": "Disabled", + "1": "Smoke Alarm", + "2": "CO Alarm", + "3": "CO2 Alarm", + "4": "Heat Alarm", + "5": "Water Alarm", + "6": "Access Control", + "7": "Home Security", + "8": "Power Management", + "9": "System", + "10": "Emergency Alarm", + "11": "Clock", + "12": "Appliance", + "13": "Home Health", + "14": "Siren", + "15": "Water Valve", + "16": "Weather Alarm", + "17": "Irrigation", + "18": "Gas Alarm", + "19": "Pest Control", + "20": "Light Sensor", + "21": "Water Quality Monitoring", + "22": "Home Monitoring" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 33, + "propertyKey": 16711680, + "propertyName": "Alarm Configuration - 4th Slot Notification Event", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Alarm Configuration - 4th Slot Notification Event", + "default": 255, + "min": 0, + "max": 255, + "states": { + "0": "Disabled", + "255": "Any Notification," + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 33, + "propertyKey": 65280, + "propertyName": "Alarm Configuration - 4th Slot Notification Event Parameter", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Alarm Configuration - 4th Slot Notification Event Parameter", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 33, + "propertyKey": 255, + "propertyName": "Alarm Configuration - 4th Slot Action", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Alarm Configuration - 4th Slot Action", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "No Action", + "1": "Open", + "2": "Close" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 34, + "propertyKey": 4278190080, + "propertyName": "Alarm Configuration - 5th Slot Notification Type", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Alarm Configuration - 5th Slot Notification Type", + "default": 4, + "min": 0, + "max": 22, + "states": { + "0": "Disabled", + "1": "Smoke Alarm", + "2": "CO Alarm", + "3": "CO2 Alarm", + "4": "Heat Alarm", + "5": "Water Alarm", + "6": "Access Control", + "7": "Home Security", + "8": "Power Management", + "9": "System", + "10": "Emergency Alarm", + "11": "Clock", + "12": "Appliance", + "13": "Home Health", + "14": "Siren", + "15": "Water Valve", + "16": "Weather Alarm", + "17": "Irrigation", + "18": "Gas Alarm", + "19": "Pest Control", + "20": "Light Sensor", + "21": "Water Quality Monitoring", + "22": "Home Monitoring" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 4 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 34, + "propertyKey": 16711680, + "propertyName": "Alarm Configuration - 5th Slot Notification Event", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Alarm Configuration - 5th Slot Notification Event", + "default": 255, + "min": 0, + "max": 255, + "states": { + "0": "Disabled", + "255": "Any Notification," + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 34, + "propertyKey": 65280, + "propertyName": "Alarm Configuration - 5th Slot Notification Event Parameter", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Alarm Configuration - 5th Slot Notification Event Parameter", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 34, + "propertyKey": 255, + "propertyName": "Alarm Configuration - 5th Slot Action", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Alarm Configuration - 5th Slot Action", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "No Action", + "1": "Open", + "2": "Close" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Access Control", + "propertyKey": "Barrier control status", + "propertyName": "Access Control", + "propertyKeyName": "Barrier control status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Barrier control status", + "ccSpecific": { + "notificationType": 6 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "76": "Barrier associated with non Z-Wave remote control" + } + }, + "value": 0 + }, + { + "endpoint": 0, + "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)" + } + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Access Control", + "propertyKey": "Barrier safety beam obstacle status", + "propertyName": "Access Control", + "propertyKeyName": "Barrier safety beam obstacle status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Barrier safety beam obstacle status", + "ccSpecific": { + "notificationType": 6 + }, + "min": 0, + "max": 255, + "states": { + "72": "Barrier safety beam obstacle" + } + } + }, + { + "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 + }, + "value": 1089 + }, + { + "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 + }, + "value": 9216 + }, + { + "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 + }, + "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" + } + }, + "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" + } + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 117, + "commandClassName": "Protection", + "property": "exclusiveControlNodeId", + "propertyName": "exclusiveControlNodeId", + "ccVersion": 2, + "metadata": { + "type": "any", + "readable": true, + "writeable": true + } + }, + { + "endpoint": 0, + "commandClass": 117, + "commandClassName": "Protection", + "property": "timeout", + "propertyName": "timeout", + "ccVersion": 2, + "metadata": { + "type": "any", + "readable": true, + "writeable": true + } + }, + { + "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" + } + }, + "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" + }, + "value": "7.13" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 3, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions" + }, + "value": ["7.0"] + }, + { + "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" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "sdkVersion", + "propertyName": "sdkVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "SDK version" + }, + "value": "7.13.1" + }, + { + "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" + }, + "value": "10.13.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" + }, + "value": 175 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceVersion", + "propertyName": "hostInterfaceVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API version" + }, + "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" + }, + "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" + }, + "value": "7.13.1" + }, + { + "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" + }, + "value": 175 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationVersion", + "propertyName": "applicationVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application version" + }, + "value": "7.0.0" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationBuildNumber", + "propertyName": "applicationBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application build number" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 3, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "Node Identify - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 3 + } + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 4, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "Node Identify - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 4 + } + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 5, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymetic On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "Node Identify - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 5 + } + }, + "value": 0 + } + ], + "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": 0, + "label": "Unused" + }, + "mandatorySupportedCCs": [32, 38], + "mandatoryControlledCCs": [] + }, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0441:0x2400:0x1000:7.0", + "statistics": { + "commandsTX": 254, + "commandsRX": 224, + "commandsDroppedRX": 47, + "commandsDroppedTX": 85, + "timeoutResponse": 4, + "rtt": 18.7 + }, + "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 ae38c82a75ce..502f2413c992 100644 --- a/tests/components/zwave_js/test_cover.py +++ b/tests/components/zwave_js/test_cover.py @@ -1,4 +1,6 @@ """Test the Z-Wave JS cover platform.""" +import logging + from zwave_js_server.const import ( CURRENT_STATE_PROPERTY, CURRENT_VALUE_PROPERTY, @@ -24,6 +26,7 @@ from homeassistant.components.cover import ( CoverDeviceClass, CoverEntityFeature, ) +from homeassistant.components.zwave_js.const import LOGGER from homeassistant.components.zwave_js.helpers import ZwaveValueMatcher from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -45,6 +48,7 @@ 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" +LOGGER.setLevel(logging.DEBUG) async def test_window_cover( @@ -795,3 +799,63 @@ async def test_iblinds_v3_cover( assert args["value"] is False client.async_send_command.reset_mock() + + +async def test_nice_ibt4zwave_cover( + hass: HomeAssistant, client, nice_ibt4zwave, integration +) -> None: + """Test Nice IBT4ZWAVE cover.""" + entity_id = "cover.portail" + state = hass.states.get(entity_id) + assert state + # This device has no state because there is no position value + assert state.state == STATE_CLOSED + assert state.attributes[ATTR_SUPPORTED_FEATURES] == ( + CoverEntityFeature.CLOSE + | CoverEntityFeature.OPEN + | CoverEntityFeature.SET_POSITION + | CoverEntityFeature.STOP + ) + assert ATTR_CURRENT_POSITION in state.attributes + assert state.attributes[ATTR_CURRENT_POSITION] == 0 + assert state.attributes[ATTR_DEVICE_CLASS] == CoverDeviceClass.GATE + + await hass.services.async_call( + DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: entity_id}, + 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"] == 72 + assert args["valueId"] == { + "endpoint": 0, + "commandClass": 38, + "property": "targetValue", + } + assert args["value"] == 0 + + client.async_send_command.reset_mock() + + await hass.services.async_call( + DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: entity_id}, + 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"] == 72 + assert args["valueId"] == { + "endpoint": 0, + "commandClass": 38, + "property": "targetValue", + } + assert args["value"] == 99 + + client.async_send_command.reset_mock() From 177cd0f6975b86c91b95282037075ec84f049735 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Fri, 2 Jun 2023 06:12:32 -0400 Subject: [PATCH 780/822] Improve logic for zwave_js.lock.is_locked attr (#93947) --- homeassistant/components/zwave_js/lock.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/zwave_js/lock.py b/homeassistant/components/zwave_js/lock.py index ff4cb84b47ce..5457916a1e13 100644 --- a/homeassistant/components/zwave_js/lock.py +++ b/homeassistant/components/zwave_js/lock.py @@ -99,14 +99,17 @@ class ZWaveLock(ZWaveBaseEntity, LockEntity): @property def is_locked(self) -> bool | None: """Return true if the lock is locked.""" - if self.info.primary_value.value is None: + value = self.info.primary_value + if value.value is None or ( + value.command_class == CommandClass.DOOR_LOCK + and value.value == DoorLockMode.UNKNOWN + ): # guard missing value return None - return int( - LOCK_CMD_CLASS_TO_LOCKED_STATE_MAP[ - CommandClass(self.info.primary_value.command_class) - ] - ) == int(self.info.primary_value.value) + return ( + LOCK_CMD_CLASS_TO_LOCKED_STATE_MAP[CommandClass(value.command_class)] + == self.info.primary_value.value + ) async def _set_lock_state(self, target_state: str, **kwargs: Any) -> None: """Set the lock state.""" From 32f7f39ecace97bbf996e73298e307e853cfc135 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 2 Jun 2023 08:46:29 -0400 Subject: [PATCH 781/822] Bumped version to 2023.6.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 e6dbcc96d0d1..1ac0da081df3 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -8,7 +8,7 @@ from .backports.enum import StrEnum APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 MINOR_VERSION: Final = 6 -PATCH_VERSION: Final = "0b2" +PATCH_VERSION: Final = "0b3" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 10, 0) diff --git a/pyproject.toml b/pyproject.toml index 04197ba7b1d0..ac39a138984e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.6.0b2" +version = "2023.6.0b3" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 6ff55a650556c192d464611ef539b01afcb23de0 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Jun 2023 05:35:11 +0200 Subject: [PATCH 782/822] Add scan interval to Command Line (#93752) * Add scan interval * Handle previous not complete * Fix faulty text * Add tests * lingering * Cool down * Fix tests --- .../components/command_line/__init__.py | 25 ++++++- .../components/command_line/binary_sensor.py | 47 +++++++++++- .../components/command_line/const.py | 4 ++ .../components/command_line/cover.py | 71 +++++++++++++++---- .../components/command_line/sensor.py | 61 ++++++++++++---- .../components/command_line/switch.py | 60 +++++++++++++--- .../command_line/test_binary_sensor.py | 63 ++++++++++++++++ tests/components/command_line/test_cover.py | 58 +++++++++++++++ tests/components/command_line/test_sensor.py | 56 +++++++++++++++ tests/components/command_line/test_switch.py | 59 +++++++++++++++ 10 files changed, 463 insertions(+), 41 deletions(-) diff --git a/homeassistant/components/command_line/__init__.py b/homeassistant/components/command_line/__init__.py index 651094db7f19..c9c18fe54a80 100644 --- a/homeassistant/components/command_line/__init__.py +++ b/homeassistant/components/command_line/__init__.py @@ -11,16 +11,24 @@ import voluptuous as vol from homeassistant.components.binary_sensor import ( DEVICE_CLASSES_SCHEMA as BINARY_SENSOR_DEVICE_CLASSES_SCHEMA, DOMAIN as BINARY_SENSOR_DOMAIN, + SCAN_INTERVAL as BINARY_SENSOR_DEFAULT_SCAN_INTERVAL, +) +from homeassistant.components.cover import ( + DOMAIN as COVER_DOMAIN, + SCAN_INTERVAL as COVER_DEFAULT_SCAN_INTERVAL, ) -from homeassistant.components.cover import DOMAIN as COVER_DOMAIN from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN from homeassistant.components.sensor import ( CONF_STATE_CLASS, DEVICE_CLASSES_SCHEMA as SENSOR_DEVICE_CLASSES_SCHEMA, DOMAIN as SENSOR_DOMAIN, + SCAN_INTERVAL as SENSOR_DEFAULT_SCAN_INTERVAL, STATE_CLASSES_SCHEMA as SENSOR_STATE_CLASSES_SCHEMA, ) -from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SCAN_INTERVAL as SWITCH_DEFAULT_SCAN_INTERVAL, +) from homeassistant.const import ( CONF_COMMAND, CONF_COMMAND_CLOSE, @@ -34,6 +42,7 @@ from homeassistant.const import ( CONF_NAME, CONF_PAYLOAD_OFF, CONF_PAYLOAD_ON, + CONF_SCAN_INTERVAL, CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE, @@ -74,6 +83,9 @@ BINARY_SENSOR_SCHEMA = vol.Schema( vol.Optional(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional( + CONF_SCAN_INTERVAL, default=BINARY_SENSOR_DEFAULT_SCAN_INTERVAL + ): vol.All(cv.time_period, cv.positive_timedelta), } ) COVER_SCHEMA = vol.Schema( @@ -86,6 +98,9 @@ COVER_SCHEMA = vol.Schema( vol.Optional(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional(CONF_SCAN_INTERVAL, default=COVER_DEFAULT_SCAN_INTERVAL): vol.All( + cv.time_period, cv.positive_timedelta + ), } ) NOTIFY_SCHEMA = vol.Schema( @@ -106,6 +121,9 @@ SENSOR_SCHEMA = vol.Schema( vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_DEVICE_CLASS): SENSOR_DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_STATE_CLASS): SENSOR_STATE_CLASSES_SCHEMA, + vol.Optional(CONF_SCAN_INTERVAL, default=SENSOR_DEFAULT_SCAN_INTERVAL): vol.All( + cv.time_period, cv.positive_timedelta + ), } ) SWITCH_SCHEMA = vol.Schema( @@ -118,6 +136,9 @@ SWITCH_SCHEMA = vol.Schema( vol.Optional(CONF_ICON): cv.template, vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional(CONF_SCAN_INTERVAL, default=SWITCH_DEFAULT_SCAN_INTERVAL): vol.All( + cv.time_period, cv.positive_timedelta + ), } ) COMBINED_SCHEMA = vol.Schema( diff --git a/homeassistant/components/command_line/binary_sensor.py b/homeassistant/components/command_line/binary_sensor.py index 18b3cf71eb03..9c5a1ce1bbeb 100644 --- a/homeassistant/components/command_line/binary_sensor.py +++ b/homeassistant/components/command_line/binary_sensor.py @@ -1,6 +1,7 @@ """Support for custom shell commands to retrieve values.""" from __future__ import annotations +import asyncio from datetime import timedelta import voluptuous as vol @@ -18,17 +19,19 @@ from homeassistant.const import ( CONF_NAME, CONF_PAYLOAD_OFF, CONF_PAYLOAD_ON, + CONF_SCAN_INTERVAL, CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, ) from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN +from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, LOGGER from .sensor import CommandSensorData DEFAULT_NAME = "Binary Command Sensor" @@ -84,6 +87,9 @@ async def async_setup_platform( value_template: Template | None = binary_sensor_config.get(CONF_VALUE_TEMPLATE) command_timeout: int = binary_sensor_config[CONF_COMMAND_TIMEOUT] unique_id: str | None = binary_sensor_config.get(CONF_UNIQUE_ID) + scan_interval: timedelta = binary_sensor_config.get( + CONF_SCAN_INTERVAL, SCAN_INTERVAL + ) if value_template is not None: value_template.hass = hass data = CommandSensorData(hass, command, command_timeout) @@ -98,6 +104,7 @@ async def async_setup_platform( payload_off, value_template, unique_id, + scan_interval, ) ], True, @@ -107,6 +114,8 @@ async def async_setup_platform( class CommandBinarySensor(BinarySensorEntity): """Representation of a command line binary sensor.""" + _attr_should_poll = False + def __init__( self, data: CommandSensorData, @@ -116,6 +125,7 @@ class CommandBinarySensor(BinarySensorEntity): payload_off: str, value_template: Template | None, unique_id: str | None, + scan_interval: timedelta, ) -> None: """Initialize the Command line binary sensor.""" self.data = data @@ -126,8 +136,39 @@ class CommandBinarySensor(BinarySensorEntity): self._payload_off = payload_off self._value_template = value_template self._attr_unique_id = unique_id + self._scan_interval = scan_interval + self._process_updates: asyncio.Lock | None = None - async def async_update(self) -> None: + async def async_added_to_hass(self) -> None: + """Call when entity about to be added to hass.""" + await super().async_added_to_hass() + await self._update_entity_state(None) + self.async_on_remove( + async_track_time_interval( + self.hass, + self._update_entity_state, + self._scan_interval, + name=f"Command Line Binary Sensor - {self.name}", + cancel_on_shutdown=True, + ), + ) + + async def _update_entity_state(self, now) -> None: + """Update the state of the entity.""" + if self._process_updates is None: + self._process_updates = asyncio.Lock() + if self._process_updates.locked(): + LOGGER.warning( + "Updating Command Line Binary Sensor %s took longer than the scheduled update interval %s", + self.name, + self._scan_interval, + ) + return + + async with self._process_updates: + await self._async_update() + + async def _async_update(self) -> None: """Get the latest data and updates the state.""" await self.hass.async_add_executor_job(self.data.update) value = self.data.value @@ -141,3 +182,5 @@ class CommandBinarySensor(BinarySensorEntity): self._attr_is_on = True elif value == self._payload_off: self._attr_is_on = False + + self.async_write_ha_state() diff --git a/homeassistant/components/command_line/const.py b/homeassistant/components/command_line/const.py index 4394f3889105..ff51cb7e3313 100644 --- a/homeassistant/components/command_line/const.py +++ b/homeassistant/components/command_line/const.py @@ -1,7 +1,11 @@ """Allows to configure custom shell commands to turn a value for a sensor.""" +import logging + from homeassistant.const import Platform +LOGGER = logging.getLogger(__package__) + CONF_COMMAND_TIMEOUT = "command_timeout" DEFAULT_TIMEOUT = 15 DOMAIN = "command_line" diff --git a/homeassistant/components/command_line/cover.py b/homeassistant/components/command_line/cover.py index 4503ceb8e569..2d2dc8c5fc2b 100644 --- a/homeassistant/components/command_line/cover.py +++ b/homeassistant/components/command_line/cover.py @@ -1,7 +1,8 @@ """Support for command line covers.""" from __future__ import annotations -import logging +import asyncio +from datetime import timedelta from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -19,21 +20,23 @@ from homeassistant.const import ( CONF_COVERS, CONF_FRIENDLY_NAME, CONF_NAME, + CONF_SCAN_INTERVAL, CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, ) from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import slugify -from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN +from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, LOGGER from .utils import call_shell_with_timeout, check_output_or_log -_LOGGER = logging.getLogger(__name__) +SCAN_INTERVAL = timedelta(seconds=15) COVER_SCHEMA = vol.Schema( { @@ -97,11 +100,12 @@ async def async_setup_platform( value_template, device_config[CONF_COMMAND_TIMEOUT], device_config.get(CONF_UNIQUE_ID), + device_config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL), ) ) if not covers: - _LOGGER.error("No covers added") + LOGGER.error("No covers added") return async_add_entities(covers) @@ -110,6 +114,8 @@ async def async_setup_platform( class CommandCover(CoverEntity): """Representation a command line cover.""" + _attr_should_poll = False + def __init__( self, name: str, @@ -120,6 +126,7 @@ class CommandCover(CoverEntity): value_template: Template | None, timeout: int, unique_id: str | None, + scan_interval: timedelta, ) -> None: """Initialize the cover.""" self._attr_name = name @@ -131,17 +138,32 @@ class CommandCover(CoverEntity): self._value_template = value_template self._timeout = timeout self._attr_unique_id = unique_id - self._attr_should_poll = bool(command_state) + self._scan_interval = scan_interval + self._process_updates: asyncio.Lock | None = None + + async def async_added_to_hass(self) -> None: + """Call when entity about to be added to hass.""" + await super().async_added_to_hass() + if self._command_state: + self.async_on_remove( + async_track_time_interval( + self.hass, + self._update_entity_state, + self._scan_interval, + name=f"Command Line Cover - {self.name}", + cancel_on_shutdown=True, + ), + ) def _move_cover(self, command: str) -> bool: """Execute the actual commands.""" - _LOGGER.info("Running command: %s", command) + LOGGER.info("Running command: %s", command) returncode = call_shell_with_timeout(command, self._timeout) success = returncode == 0 if not success: - _LOGGER.error( + LOGGER.error( "Command failed (with return code %s): %s", returncode, command ) @@ -165,12 +187,27 @@ class CommandCover(CoverEntity): def _query_state(self) -> str | None: """Query for the state.""" if self._command_state: - _LOGGER.info("Running state value command: %s", self._command_state) + LOGGER.info("Running state value command: %s", self._command_state) return check_output_or_log(self._command_state, self._timeout) if TYPE_CHECKING: return None - async def async_update(self) -> None: + async def _update_entity_state(self, now) -> None: + """Update the state of the entity.""" + if self._process_updates is None: + self._process_updates = asyncio.Lock() + if self._process_updates.locked(): + LOGGER.warning( + "Updating Command Line Cover %s took longer than the scheduled update interval %s", + self.name, + self._scan_interval, + ) + return + + async with self._process_updates: + await self._async_update() + + async def _async_update(self) -> None: """Update device state.""" if self._command_state: payload = str(await self.hass.async_add_executor_job(self._query_state)) @@ -181,15 +218,19 @@ class CommandCover(CoverEntity): self._state = None if payload: self._state = int(payload) + await self.async_update_ha_state(True) - def open_cover(self, **kwargs: Any) -> None: + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - self._move_cover(self._command_open) + await self.hass.async_add_executor_job(self._move_cover, self._command_open) + await self._update_entity_state(None) - def close_cover(self, **kwargs: Any) -> None: + async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" - self._move_cover(self._command_close) + await self.hass.async_add_executor_job(self._move_cover, self._command_close) + await self._update_entity_state(None) - def stop_cover(self, **kwargs: Any) -> None: + async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" - self._move_cover(self._command_stop) + await self.hass.async_add_executor_job(self._move_cover, self._command_stop) + await self._update_entity_state(None) diff --git a/homeassistant/components/command_line/sensor.py b/homeassistant/components/command_line/sensor.py index 1689b136f2fc..f42ac0620810 100644 --- a/homeassistant/components/command_line/sensor.py +++ b/homeassistant/components/command_line/sensor.py @@ -1,10 +1,10 @@ """Allows to configure custom shell commands to turn a value for a sensor.""" from __future__ import annotations +import asyncio from collections.abc import Mapping from datetime import timedelta import json -import logging import voluptuous as vol @@ -20,6 +20,7 @@ from homeassistant.const import ( CONF_COMMAND, CONF_DEVICE_CLASS, CONF_NAME, + CONF_SCAN_INTERVAL, CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE, @@ -28,15 +29,14 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import TemplateError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN +from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, LOGGER from .utils import check_output_or_log -_LOGGER = logging.getLogger(__name__) - CONF_JSON_ATTRIBUTES = "json_attributes" DEFAULT_NAME = "Command Sensor" @@ -88,6 +88,7 @@ async def async_setup_platform( if value_template is not None: value_template.hass = hass json_attributes: list[str] | None = sensor_config.get(CONF_JSON_ATTRIBUTES) + scan_interval: timedelta = sensor_config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL) data = CommandSensorData(hass, command, command_timeout) async_add_entities( @@ -99,15 +100,17 @@ async def async_setup_platform( value_template, json_attributes, unique_id, + scan_interval, ) - ], - True, + ] ) class CommandSensor(SensorEntity): """Representation of a sensor that is using shell commands.""" + _attr_should_poll = False + def __init__( self, data: CommandSensorData, @@ -116,6 +119,7 @@ class CommandSensor(SensorEntity): value_template: Template | None, json_attributes: list[str] | None, unique_id: str | None, + scan_interval: timedelta, ) -> None: """Initialize the sensor.""" self._attr_name = name @@ -126,8 +130,39 @@ class CommandSensor(SensorEntity): self._value_template = value_template self._attr_native_unit_of_measurement = unit_of_measurement self._attr_unique_id = unique_id + self._scan_interval = scan_interval + self._process_updates: asyncio.Lock | None = None - async def async_update(self) -> None: + async def async_added_to_hass(self) -> None: + """Call when entity about to be added to hass.""" + await super().async_added_to_hass() + await self._update_entity_state(None) + self.async_on_remove( + async_track_time_interval( + self.hass, + self._update_entity_state, + self._scan_interval, + name=f"Command Line Sensor - {self.name}", + cancel_on_shutdown=True, + ), + ) + + async def _update_entity_state(self, now) -> None: + """Update the state of the entity.""" + if self._process_updates is None: + self._process_updates = asyncio.Lock() + if self._process_updates.locked(): + LOGGER.warning( + "Updating Command Line Sensor %s took longer than the scheduled update interval %s", + self.name, + self._scan_interval, + ) + return + + async with self._process_updates: + await self._async_update() + + async def _async_update(self) -> None: """Get the latest data and updates the state.""" await self.hass.async_add_executor_job(self.data.update) value = self.data.value @@ -144,11 +179,11 @@ class CommandSensor(SensorEntity): if k in json_dict } else: - _LOGGER.warning("JSON result was not a dictionary") + LOGGER.warning("JSON result was not a dictionary") except ValueError: - _LOGGER.warning("Unable to parse output as JSON: %s", value) + LOGGER.warning("Unable to parse output as JSON: %s", value) else: - _LOGGER.warning("Empty reply found when expecting JSON data") + LOGGER.warning("Empty reply found when expecting JSON data") if self._value_template is None: self._attr_native_value = None return @@ -163,6 +198,8 @@ class CommandSensor(SensorEntity): else: self._attr_native_value = value + self.async_write_ha_state() + class CommandSensorData: """The class for handling the data retrieval.""" @@ -191,7 +228,7 @@ class CommandSensorData: args_to_render = {"arguments": args} rendered_args = args_compiled.render(args_to_render) except TemplateError as ex: - _LOGGER.exception("Error rendering command template: %s", ex) + LOGGER.exception("Error rendering command template: %s", ex) return else: rendered_args = None @@ -203,5 +240,5 @@ class CommandSensorData: # Template used. Construct the string used in the shell command = f"{prog} {rendered_args}" - _LOGGER.debug("Running command: %s", command) + LOGGER.debug("Running command: %s", command) self.value = check_output_or_log(command, self.timeout) diff --git a/homeassistant/components/command_line/switch.py b/homeassistant/components/command_line/switch.py index 7936bacd432c..1a3dd39a342d 100644 --- a/homeassistant/components/command_line/switch.py +++ b/homeassistant/components/command_line/switch.py @@ -1,7 +1,8 @@ """Support for custom shell commands to turn a switch on/off.""" from __future__ import annotations -import logging +import asyncio +from datetime import timedelta from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -20,6 +21,7 @@ from homeassistant.const import ( CONF_ICON, CONF_ICON_TEMPLATE, CONF_NAME, + CONF_SCAN_INTERVAL, CONF_SWITCHES, CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, @@ -27,16 +29,17 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.template import Template from homeassistant.helpers.template_entity import ManualTriggerEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import slugify -from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN +from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, LOGGER from .utils import call_shell_with_timeout, check_output_or_log -_LOGGER = logging.getLogger(__name__) +SCAN_INTERVAL = timedelta(seconds=30) SWITCH_SCHEMA = vol.Schema( { @@ -112,11 +115,12 @@ async def async_setup_platform( device_config.get(CONF_COMMAND_STATE), value_template, device_config[CONF_COMMAND_TIMEOUT], + device_config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL), ) ) if not switches: - _LOGGER.error("No switches added") + LOGGER.error("No switches added") return async_add_entities(switches) @@ -125,6 +129,8 @@ async def async_setup_platform( class CommandSwitch(ManualTriggerEntity, SwitchEntity): """Representation a switch that can be toggled using shell commands.""" + _attr_should_poll = False + def __init__( self, config: ConfigType, @@ -134,6 +140,7 @@ class CommandSwitch(ManualTriggerEntity, SwitchEntity): command_state: str | None, value_template: Template | None, timeout: int, + scan_interval: timedelta, ) -> None: """Initialize the switch.""" super().__init__(self.hass, config) @@ -144,11 +151,26 @@ class CommandSwitch(ManualTriggerEntity, SwitchEntity): self._command_state = command_state self._value_template = value_template self._timeout = timeout - self._attr_should_poll = bool(command_state) + self._scan_interval = scan_interval + self._process_updates: asyncio.Lock | None = None + + async def async_added_to_hass(self) -> None: + """Call when entity about to be added to hass.""" + await super().async_added_to_hass() + if self._command_state: + self.async_on_remove( + async_track_time_interval( + self.hass, + self._update_entity_state, + self._scan_interval, + name=f"Command Line Cover - {self.name}", + cancel_on_shutdown=True, + ), + ) async def _switch(self, command: str) -> bool: """Execute the actual commands.""" - _LOGGER.info("Running command: %s", command) + LOGGER.info("Running command: %s", command) success = ( await self.hass.async_add_executor_job( @@ -158,18 +180,18 @@ class CommandSwitch(ManualTriggerEntity, SwitchEntity): ) if not success: - _LOGGER.error("Command failed: %s", command) + LOGGER.error("Command failed: %s", command) return success def _query_state_value(self, command: str) -> str | None: """Execute state command for return value.""" - _LOGGER.info("Running state value command: %s", command) + LOGGER.info("Running state value command: %s", command) return check_output_or_log(command, self._timeout) def _query_state_code(self, command: str) -> bool: """Execute state command for return code.""" - _LOGGER.info("Running state code command: %s", command) + LOGGER.info("Running state code command: %s", command) return ( call_shell_with_timeout(command, self._timeout, log_return_code=False) == 0 ) @@ -188,7 +210,22 @@ class CommandSwitch(ManualTriggerEntity, SwitchEntity): if TYPE_CHECKING: return None - async def async_update(self) -> None: + async def _update_entity_state(self, now) -> None: + """Update the state of the entity.""" + if self._process_updates is None: + self._process_updates = asyncio.Lock() + if self._process_updates.locked(): + LOGGER.warning( + "Updating Command Line Switch %s took longer than the scheduled update interval %s", + self.name, + self._scan_interval, + ) + return + + async with self._process_updates: + await self._async_update() + + async def _async_update(self) -> None: """Update device state.""" if self._command_state: payload = str(await self.hass.async_add_executor_job(self._query_state)) @@ -201,15 +238,18 @@ class CommandSwitch(ManualTriggerEntity, SwitchEntity): if payload or value: self._attr_is_on = (value or payload).lower() == "true" self._process_manual_data(payload) + await self.async_update_ha_state(True) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" if await self._switch(self._command_on) and not self._command_state: self._attr_is_on = True self.async_schedule_update_ha_state() + await self._update_entity_state(None) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" if await self._switch(self._command_off) and not self._command_state: self._attr_is_on = False self.async_schedule_update_ha_state() + await self._update_entity_state(None) diff --git a/tests/components/command_line/test_binary_sensor.py b/tests/components/command_line/test_binary_sensor.py index 6f79b6bdacfe..eb6b52a66bee 100644 --- a/tests/components/command_line/test_binary_sensor.py +++ b/tests/components/command_line/test_binary_sensor.py @@ -1,17 +1,24 @@ """The tests for the Command line Binary sensor platform.""" from __future__ import annotations +import asyncio +from datetime import timedelta from typing import Any +from unittest.mock import patch import pytest from homeassistant import setup from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.command_line.binary_sensor import CommandBinarySensor from homeassistant.components.command_line.const import DOMAIN from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er import homeassistant.helpers.issue_registry as ir +from homeassistant.util import dt as dt_util + +from tests.common import async_fire_time_changed async def test_setup_platform_yaml(hass: HomeAssistant) -> None: @@ -189,3 +196,59 @@ async def test_return_code( ) await hass.async_block_till_done() assert "return code 33" in caplog.text + + +async def test_updating_to_often( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test handling updating when command already running.""" + called = [] + + class MockCommandBinarySensor(CommandBinarySensor): + """Mock entity that updates slow.""" + + async def _async_update(self) -> None: + """Update slow.""" + called.append(1) + # Add waiting time + await asyncio.sleep(1) + + with patch( + "homeassistant.components.command_line.binary_sensor.CommandBinarySensor", + side_effect=MockCommandBinarySensor, + ): + await setup.async_setup_component( + hass, + DOMAIN, + { + "command_line": [ + { + "binary_sensor": { + "name": "Test", + "command": "echo 1", + "payload_on": "1", + "payload_off": "0", + "scan_interval": 0.1, + } + } + ] + }, + ) + await hass.async_block_till_done() + + assert len(called) == 1 + assert ( + "Updating Command Line Binary Sensor Test took longer than the scheduled update interval" + not in caplog.text + ) + + async_fire_time_changed(hass, dt_util.now() + timedelta(seconds=1)) + await hass.async_block_till_done() + + assert len(called) == 2 + assert ( + "Updating Command Line Binary Sensor Test took longer than the scheduled update interval" + in caplog.text + ) + + await asyncio.sleep(0.2) diff --git a/tests/components/command_line/test_cover.py b/tests/components/command_line/test_cover.py index 057e632c325f..d977c202b049 100644 --- a/tests/components/command_line/test_cover.py +++ b/tests/components/command_line/test_cover.py @@ -1,6 +1,8 @@ """The tests the cover command line platform.""" from __future__ import annotations +import asyncio +from datetime import timedelta import os import tempfile from unittest.mock import patch @@ -9,6 +11,7 @@ import pytest from homeassistant import config as hass_config, setup from homeassistant.components.command_line import DOMAIN +from homeassistant.components.command_line.cover import CommandCover from homeassistant.components.cover import DOMAIN as COVER_DOMAIN, SCAN_INTERVAL from homeassistant.const import ( ATTR_ENTITY_ID, @@ -320,3 +323,58 @@ async def test_unique_id( assert entity_registry.async_get_entity_id( "cover", "command_line", "not-so-unique-anymore" ) + + +async def test_updating_to_often( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test handling updating when command already running.""" + called = [] + + class MockCommandCover(CommandCover): + """Mock entity that updates slow.""" + + async def _async_update(self) -> None: + """Update slow.""" + called.append(1) + # Add waiting time + await asyncio.sleep(1) + + with patch( + "homeassistant.components.command_line.cover.CommandCover", + side_effect=MockCommandCover, + ): + await setup.async_setup_component( + hass, + DOMAIN, + { + "command_line": [ + { + "cover": { + "command_state": "echo 1", + "value_template": "{{ value }}", + "name": "Test", + "scan_interval": 0.1, + } + } + ] + }, + ) + await hass.async_block_till_done() + + assert len(called) == 0 + assert ( + "Updating Command Line Cover Test took longer than the scheduled update interval" + not in caplog.text + ) + + async_fire_time_changed(hass, dt_util.now() + timedelta(seconds=1)) + await hass.async_block_till_done() + + assert len(called) == 1 + assert ( + "Updating Command Line Cover Test took longer than the scheduled update interval" + in caplog.text + ) + + await asyncio.sleep(0.2) diff --git a/tests/components/command_line/test_sensor.py b/tests/components/command_line/test_sensor.py index 7491e7011f5e..87360d0e251b 100644 --- a/tests/components/command_line/test_sensor.py +++ b/tests/components/command_line/test_sensor.py @@ -1,6 +1,7 @@ """The tests for the Command line sensor platform.""" from __future__ import annotations +import asyncio from datetime import timedelta from typing import Any from unittest.mock import patch @@ -9,6 +10,7 @@ import pytest from homeassistant import setup from homeassistant.components.command_line import DOMAIN +from homeassistant.components.command_line.sensor import CommandSensor from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -530,3 +532,57 @@ async def test_unique_id( assert entity_registry.async_get_entity_id( "sensor", "command_line", "not-so-unique-anymore" ) + + +async def test_updating_to_often( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test handling updating when command already running.""" + called = [] + + class MockCommandSensor(CommandSensor): + """Mock entity that updates slow.""" + + async def _async_update(self) -> None: + """Update slow.""" + called.append(1) + # Add waiting time + await asyncio.sleep(1) + + with patch( + "homeassistant.components.command_line.sensor.CommandSensor", + side_effect=MockCommandSensor, + ): + await setup.async_setup_component( + hass, + DOMAIN, + { + "command_line": [ + { + "sensor": { + "name": "Test", + "command": "echo 1", + "scan_interval": 0.1, + } + } + ] + }, + ) + await hass.async_block_till_done() + + assert len(called) == 1 + assert ( + "Updating Command Line Sensor Test took longer than the scheduled update interval" + not in caplog.text + ) + + async_fire_time_changed(hass, dt_util.now() + timedelta(seconds=1)) + await hass.async_block_till_done() + + assert len(called) == 2 + assert ( + "Updating Command Line Sensor Test took longer than the scheduled update interval" + in caplog.text + ) + + await asyncio.sleep(0.2) diff --git a/tests/components/command_line/test_switch.py b/tests/components/command_line/test_switch.py index 017c453aa8b6..88a875883752 100644 --- a/tests/components/command_line/test_switch.py +++ b/tests/components/command_line/test_switch.py @@ -1,6 +1,8 @@ """The tests for the Command line switch platform.""" from __future__ import annotations +import asyncio +from datetime import timedelta import json import os import subprocess @@ -11,6 +13,7 @@ import pytest from homeassistant import setup from homeassistant.components.command_line import DOMAIN +from homeassistant.components.command_line.switch import CommandSwitch from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SCAN_INTERVAL from homeassistant.const import ( ATTR_ENTITY_ID, @@ -637,3 +640,59 @@ async def test_templating(hass: HomeAssistant) -> None: assert entity_state.attributes.get("icon") == "mdi:on" assert entity_state2.state == STATE_ON assert entity_state2.attributes.get("icon") == "mdi:on" + + +async def test_updating_to_often( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test handling updating when command already running.""" + called = [] + + class MockCommandSwitch(CommandSwitch): + """Mock entity that updates slow.""" + + async def _async_update(self) -> None: + """Update slow.""" + called.append(1) + # Add waiting time + await asyncio.sleep(1) + + with patch( + "homeassistant.components.command_line.switch.CommandSwitch", + side_effect=MockCommandSwitch, + ): + await setup.async_setup_component( + hass, + DOMAIN, + { + "command_line": [ + { + "switch": { + "command_state": "echo 1", + "command_on": "echo 2", + "command_off": "echo 3", + "name": "Test", + "scan_interval": 0.1, + } + } + ] + }, + ) + await hass.async_block_till_done() + + assert len(called) == 0 + assert ( + "Updating Command Line Switch Test took longer than the scheduled update interval" + not in caplog.text + ) + + async_fire_time_changed(hass, dt_util.now() + timedelta(seconds=1)) + await hass.async_block_till_done() + + assert len(called) == 1 + assert ( + "Updating Command Line Switch Test took longer than the scheduled update interval" + in caplog.text + ) + + await asyncio.sleep(0.2) From f92298c6fc092c6149daeebfed8cf58681912d38 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Fri, 2 Jun 2023 16:18:58 -0400 Subject: [PATCH 783/822] Catch Google Sheets api error (#93979) --- .../components/google_sheets/__init__.py | 10 +++++- tests/components/google_sheets/test_init.py | 35 ++++++++++++++++++- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/google_sheets/__init__.py b/homeassistant/components/google_sheets/__init__.py index 803b737283b3..590c7bd0c902 100644 --- a/homeassistant/components/google_sheets/__init__.py +++ b/homeassistant/components/google_sheets/__init__.py @@ -7,13 +7,18 @@ import aiohttp from google.auth.exceptions import RefreshError from google.oauth2.credentials import Credentials from gspread import Client +from gspread.exceptions import APIError from gspread.utils import ValueInputOption import voluptuous as vol from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + HomeAssistantError, +) from homeassistant.helpers.config_entry_oauth2_flow import ( OAuth2Session, async_get_config_entry_implementation, @@ -93,6 +98,9 @@ async def async_setup_service(hass: HomeAssistant) -> None: except RefreshError as ex: entry.async_start_reauth(hass) raise ex + except APIError as ex: + raise HomeAssistantError("Failed to write data") from ex + worksheet = sheet.worksheet(call.data.get(WORKSHEET, sheet.sheet1.title)) row_data = {"created": str(datetime.now())} | call.data[DATA] columns: list[str] = next(iter(worksheet.get_values("A1:ZZ1")), []) diff --git a/tests/components/google_sheets/test_init.py b/tests/components/google_sheets/test_init.py index 50c82ac5109f..8f7ce7603e8e 100644 --- a/tests/components/google_sheets/test_init.py +++ b/tests/components/google_sheets/test_init.py @@ -6,7 +6,9 @@ import time from typing import Any from unittest.mock import patch +from gspread.exceptions import APIError import pytest +from requests.models import Response from homeassistant.components.application_credentials import ( ClientCredential, @@ -15,7 +17,7 @@ from homeassistant.components.application_credentials import ( from homeassistant.components.google_sheets import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ServiceNotFound +from homeassistant.exceptions import HomeAssistantError, ServiceNotFound from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -212,6 +214,37 @@ async def test_append_sheet( assert len(mock_client.mock_calls) == 8 +async def test_append_sheet_api_error( + hass: HomeAssistant, + setup_integration: ComponentSetup, + config_entry: MockConfigEntry, +) -> None: + """Test append to sheet service call API error.""" + await setup_integration() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + + response = Response() + response.status_code = 503 + + with pytest.raises(HomeAssistantError), patch( + "homeassistant.components.google_sheets.Client.request", + side_effect=APIError(response), + ): + await hass.services.async_call( + DOMAIN, + "append_sheet", + { + "config_entry": config_entry.entry_id, + "worksheet": "Sheet1", + "data": {"foo": "bar"}, + }, + blocking=True, + ) + + async def test_append_sheet_invalid_config_entry( hass: HomeAssistant, setup_integration: ComponentSetup, From bb2a89f0653c414aec885824b7c68df01bbecd2b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 2 Jun 2023 23:35:41 -0400 Subject: [PATCH 784/822] Bumped version to 2023.6.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 1ac0da081df3..638a4afe037f 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -8,7 +8,7 @@ from .backports.enum import StrEnum APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 MINOR_VERSION: Final = 6 -PATCH_VERSION: Final = "0b3" +PATCH_VERSION: Final = "0b4" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 10, 0) diff --git a/pyproject.toml b/pyproject.toml index ac39a138984e..006692082b59 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.6.0b3" +version = "2023.6.0b4" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 9aeba6221b01a0232b133ddc95ea86d1956a7247 Mon Sep 17 00:00:00 2001 From: tronikos Date: Sat, 3 Jun 2023 06:25:39 -0700 Subject: [PATCH 785/822] Fix error in tibber while fetching latest statistics (#93998) --- homeassistant/components/tibber/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py index a2f1db7536fa..242c2179a05c 100644 --- a/homeassistant/components/tibber/sensor.py +++ b/homeassistant/components/tibber/sensor.py @@ -606,7 +606,7 @@ class TibberDataCoordinator(DataUpdateCoordinator[None]): ) last_stats = await get_instance(self.hass).async_add_executor_job( - get_last_statistics, self.hass, 1, statistic_id, True, {} + get_last_statistics, self.hass, 1, statistic_id, True, set() ) if not last_stats: From 2a99fea1debe2f728db65f27fba562d109f80c76 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 1 Jun 2023 11:46:59 +0200 Subject: [PATCH 786/822] Add video id to youtube sensor state attributes (#93668) * Add video id to state attributes * Make extra state attributes not optional * Revert "Make extra state attributes not optional" This reverts commit d2f9e936c809dd50a5e4bbdaa181c9c9ddd3d217. --- homeassistant/components/youtube/sensor.py | 13 +++++++++++++ tests/components/youtube/test_sensor.py | 1 + 2 files changed, 14 insertions(+) diff --git a/homeassistant/components/youtube/sensor.py b/homeassistant/components/youtube/sensor.py index 6c75ef3bf8cf..7f92ec0786a4 100644 --- a/homeassistant/components/youtube/sensor.py +++ b/homeassistant/components/youtube/sensor.py @@ -18,6 +18,7 @@ from .const import ( ATTR_SUBSCRIBER_COUNT, ATTR_THUMBNAIL, ATTR_TITLE, + ATTR_VIDEO_ID, COORDINATOR, DOMAIN, ) @@ -30,6 +31,7 @@ class YouTubeMixin: value_fn: Callable[[Any], StateType] entity_picture_fn: Callable[[Any], str] + attributes_fn: Callable[[Any], dict[str, Any]] | None @dataclass @@ -44,6 +46,9 @@ SENSOR_TYPES = [ icon="mdi:youtube", value_fn=lambda channel: channel[ATTR_LATEST_VIDEO][ATTR_TITLE], entity_picture_fn=lambda channel: channel[ATTR_LATEST_VIDEO][ATTR_THUMBNAIL], + attributes_fn=lambda channel: { + ATTR_VIDEO_ID: channel[ATTR_LATEST_VIDEO][ATTR_VIDEO_ID] + }, ), YouTubeSensorEntityDescription( key="subscribers", @@ -52,6 +57,7 @@ SENSOR_TYPES = [ native_unit_of_measurement="subscribers", value_fn=lambda channel: channel[ATTR_SUBSCRIBER_COUNT], entity_picture_fn=lambda channel: channel[ATTR_ICON], + attributes_fn=None, ), ] @@ -84,3 +90,10 @@ class YouTubeSensor(YouTubeChannelEntity, SensorEntity): def entity_picture(self) -> str: """Return the value reported by the sensor.""" return self.entity_description.entity_picture_fn(self._channel) + + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + """Return the extra state attributes.""" + if self.entity_description.attributes_fn: + return self.entity_description.attributes_fn(self._channel) + return None diff --git a/tests/components/youtube/test_sensor.py b/tests/components/youtube/test_sensor.py index f4dbd9cc3a52..1363a4468a7b 100644 --- a/tests/components/youtube/test_sensor.py +++ b/tests/components/youtube/test_sensor.py @@ -25,6 +25,7 @@ async def test_sensor(hass: HomeAssistant, setup_integration: ComponentSetup) -> state.attributes["entity_picture"] == "https://i.ytimg.com/vi/wysukDrMdqU/sddefault.jpg" ) + assert state.attributes["video_id"] == "wysukDrMdqU" state = hass.states.get("sensor.google_for_developers_subscribers") assert state From 4f00cc9faa3c14a0c106ef276ccf7b0741deca93 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 3 Jun 2023 20:35:57 +0200 Subject: [PATCH 787/822] Show the sensor state using the coordinatordata instead of initial data (#94008) * Show the sensor state using the coordinatordata instead of initial data * Add test * Remove part --- homeassistant/components/youtube/entity.py | 17 +- homeassistant/components/youtube/sensor.py | 14 +- tests/components/youtube/__init__.py | 35 ++- .../youtube/fixtures/get_channel_2.json | 6 + .../fixtures/get_playlist_items_2.json | 215 ++++++++++++++++++ tests/components/youtube/test_sensor.py | 34 ++- 6 files changed, 298 insertions(+), 23 deletions(-) create mode 100644 tests/components/youtube/fixtures/get_playlist_items_2.json diff --git a/homeassistant/components/youtube/entity.py b/homeassistant/components/youtube/entity.py index cdc2f98faac7..2f9238dec267 100644 --- a/homeassistant/components/youtube/entity.py +++ b/homeassistant/components/youtube/entity.py @@ -1,9 +1,6 @@ """Entity representing a YouTube account.""" from __future__ import annotations -from typing import Any - -from homeassistant.const import ATTR_ID from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo, EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -21,20 +18,18 @@ class YouTubeChannelEntity(CoordinatorEntity): self, coordinator: YouTubeDataUpdateCoordinator, description: EntityDescription, - channel: dict[str, Any], + channel_id: str, ) -> None: - """Initialize a Google Mail entity.""" + """Initialize a YouTube entity.""" super().__init__(coordinator) self.entity_description = description self._attr_unique_id = ( - f"{coordinator.config_entry.entry_id}_{channel[ATTR_ID]}_{description.key}" + f"{coordinator.config_entry.entry_id}_{channel_id}_{description.key}" ) + self._channel_id = channel_id self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, - identifiers={ - (DOMAIN, f"{coordinator.config_entry.entry_id}_{channel[ATTR_ID]}") - }, + identifiers={(DOMAIN, f"{coordinator.config_entry.entry_id}_{channel_id}")}, manufacturer=MANUFACTURER, - name=channel[ATTR_TITLE], + name=coordinator.data[channel_id][ATTR_TITLE], ) - self._channel = channel diff --git a/homeassistant/components/youtube/sensor.py b/homeassistant/components/youtube/sensor.py index 7f92ec0786a4..c605b9604757 100644 --- a/homeassistant/components/youtube/sensor.py +++ b/homeassistant/components/youtube/sensor.py @@ -70,8 +70,8 @@ async def async_setup_entry( COORDINATOR ] async_add_entities( - YouTubeSensor(coordinator, sensor_type, channel) - for channel in coordinator.data.values() + YouTubeSensor(coordinator, sensor_type, channel_id) + for channel_id in coordinator.data for sensor_type in SENSOR_TYPES ) @@ -84,16 +84,20 @@ class YouTubeSensor(YouTubeChannelEntity, SensorEntity): @property def native_value(self) -> StateType: """Return the value reported by the sensor.""" - return self.entity_description.value_fn(self._channel) + return self.entity_description.value_fn(self.coordinator.data[self._channel_id]) @property def entity_picture(self) -> str: """Return the value reported by the sensor.""" - return self.entity_description.entity_picture_fn(self._channel) + return self.entity_description.entity_picture_fn( + self.coordinator.data[self._channel_id] + ) @property def extra_state_attributes(self) -> dict[str, Any] | None: """Return the extra state attributes.""" if self.entity_description.attributes_fn: - return self.entity_description.attributes_fn(self._channel) + return self.entity_description.attributes_fn( + self.coordinator.data[self._channel_id] + ) return None diff --git a/tests/components/youtube/__init__.py b/tests/components/youtube/__init__.py index 289a5e8793ff..391ff4b3a225 100644 --- a/tests/components/youtube/__init__.py +++ b/tests/components/youtube/__init__.py @@ -20,6 +20,10 @@ class MockRequest: class MockChannels: """Mock object for channels.""" + def __init__(self, fixture: str): + """Initialize mock channels.""" + self._fixture = fixture + def list( self, part: str, @@ -28,12 +32,16 @@ class MockChannels: maxResults: int | None = None, ) -> MockRequest: """Return a fixture.""" - return MockRequest(fixture="youtube/get_channel.json") + return MockRequest(fixture=self._fixture) class MockPlaylistItems: """Mock object for playlist items.""" + def __init__(self, fixture: str): + """Initialize mock playlist items.""" + self._fixture = fixture + def list( self, part: str, @@ -41,28 +49,43 @@ class MockPlaylistItems: maxResults: int | None = None, ) -> MockRequest: """Return a fixture.""" - return MockRequest(fixture="youtube/get_playlist_items.json") + return MockRequest(fixture=self._fixture) class MockSubscriptions: """Mock object for subscriptions.""" + def __init__(self, fixture: str): + """Initialize mock subscriptions.""" + self._fixture = fixture + def list(self, part: str, mine: bool, maxResults: int | None = None) -> MockRequest: """Return a fixture.""" - return MockRequest(fixture="youtube/get_subscriptions.json") + return MockRequest(fixture=self._fixture) class MockService: """Service which returns mock objects.""" + def __init__( + self, + channel_fixture: str = "youtube/get_channel.json", + playlist_items_fixture: str = "youtube/get_playlist_items.json", + subscriptions_fixture: str = "youtube/get_subscriptions.json", + ): + """Initialize mock service.""" + self._channel_fixture = channel_fixture + self._playlist_items_fixture = playlist_items_fixture + self._subscriptions_fixture = subscriptions_fixture + def channels(self) -> MockChannels: """Return a mock object.""" - return MockChannels() + return MockChannels(self._channel_fixture) def playlistItems(self) -> MockPlaylistItems: """Return a mock object.""" - return MockPlaylistItems() + return MockPlaylistItems(self._playlist_items_fixture) def subscriptions(self) -> MockSubscriptions: """Return a mock object.""" - return MockSubscriptions() + return MockSubscriptions(self._subscriptions_fixture) diff --git a/tests/components/youtube/fixtures/get_channel_2.json b/tests/components/youtube/fixtures/get_channel_2.json index 81da13f7d19c..24e71ad91abd 100644 --- a/tests/components/youtube/fixtures/get_channel_2.json +++ b/tests/components/youtube/fixtures/get_channel_2.json @@ -36,6 +36,12 @@ "totalItemCount": 6178, "newItemCount": 0, "activityType": "all" + }, + "statistics": { + "viewCount": "214141263", + "subscriberCount": "2290000", + "hiddenSubscriberCount": false, + "videoCount": "5798" } } ] diff --git a/tests/components/youtube/fixtures/get_playlist_items_2.json b/tests/components/youtube/fixtures/get_playlist_items_2.json new file mode 100644 index 000000000000..2311d7219c23 --- /dev/null +++ b/tests/components/youtube/fixtures/get_playlist_items_2.json @@ -0,0 +1,215 @@ +{ + "kind": "youtube#playlistItemListResponse", + "etag": "O0Ah8Wd5pUD2Gsv-n0A42RDRcX8", + "nextPageToken": "EAAaBlBUOkNBVQ", + "items": [ + { + "kind": "youtube#playlistItem", + "etag": "pU0v49jXONlQfIJEX7ldINttRYM", + "id": "VVVfeDVYRzFPVjJQNnVaWjVGU005VHR3LmhsZUxsY0h3UUxN", + "snippet": { + "publishedAt": "2023-05-10T22:30:48Z", + "channelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw", + "title": "Google I/O 2023 Developer Keynote in 5 minutes", + "description": "Discover what’s new from Google, including top takeaways and highlights announced at Google I/O 2023. From deep investments in the largest mobile platform, to breakthroughs in AI, learn about the latest capabilities in mobile, web, Cloud, AI, and more. \n\nCatch the full Developer Keynote →https://goo.gle/dev-keynote-23 \nWatch all the Keynotes from Google I/O 2023→ https://goo.gle/IO23_keynotes\nWatch all the Google I/O 2023 Sessions → https://goo.gle/IO23_all \n\n0:00 - Welcome\n0:25 - MakerSuite\n0:49 - Android Studio Bot\n1:38 - Large screens\n2:04 - Wear OS\n2:34 - WebGPU\n2:58 - Baseline\n3:27 - MediaPipe\n3:57 - Duet AI for Google Cloud\n4:59 - Closing\n\nSubscribe to Google Developers → https://goo.gle/developers\n\n#GoogleIO #developers", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/hleLlcHwQLM/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/hleLlcHwQLM/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/hleLlcHwQLM/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/hleLlcHwQLM/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/hleLlcHwQLM/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "Google for Developers", + "playlistId": "UU_x5XG1OV2P6uZZ5FSM9Ttw", + "position": 1, + "resourceId": { + "kind": "youtube#video", + "videoId": "hleLlcHwQLM" + }, + "videoOwnerChannelTitle": "Google for Developers", + "videoOwnerChannelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw" + }, + "contentDetails": { + "videoId": "hleLlcHwQLM", + "videoPublishedAt": "2023-05-10T22:30:48Z" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "fht9mKDuIBXcO75k21ZB_gC_4vM", + "id": "VVVfeDVYRzFPVjJQNnVaWjVGU005VHR3LmxNS2p0U0Z1amN3", + "snippet": { + "publishedAt": "2023-05-10T21:25:47Z", + "channelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw", + "title": "What's new in Google Pay and Wallet in less than 1 minute", + "description": "A quick recap on the latest updates to Google Pay and Wallet from Google I/O 2023.\n\nTo learn more about what's new in Google Pay and Wallet, check out the keynote → https://goo.gle/IO23_paywallet\n\nSubscribe to Google Developers → https://goo.gle/developers\n\n#GoogleIO", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/lMKjtSFujcw/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/lMKjtSFujcw/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/lMKjtSFujcw/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/lMKjtSFujcw/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/lMKjtSFujcw/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "Google for Developers", + "playlistId": "UU_x5XG1OV2P6uZZ5FSM9Ttw", + "position": 2, + "resourceId": { + "kind": "youtube#video", + "videoId": "lMKjtSFujcw" + }, + "videoOwnerChannelTitle": "Google for Developers", + "videoOwnerChannelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw" + }, + "contentDetails": { + "videoId": "lMKjtSFujcw", + "videoPublishedAt": "2023-05-10T21:25:47Z" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "nYKXoKd8eePAZ_xFa3dL5ZmvM5c", + "id": "VVVfeDVYRzFPVjJQNnVaWjVGU005VHR3LmMwbXFCdVhQcnBB", + "snippet": { + "publishedAt": "2023-05-10T20:47:57Z", + "channelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw", + "title": "Developers guide to BigQuery export for Google Analytics 4", + "description": "With Google Analytics 4 (GA4), anyone can set up export of granular measurement data to BigQuery.\n\nIn this session, you will learn how to use the BigQuery export for solving business problems, doing complex reporting, implementing advanced use cases with ML models, and creating custom audiences by joining with first-party data. You can use this framework for detailed or large-scale data analysis. We will also share some best practices to get you started.\n\nResources:\nDevelopers guide to BigQuery export for Google Analytics 4 → https://goo.gle/ga-io23\n\nSpeaker: Minhaz Kazi\n\nWatch more:\nWatch all the Technical Sessions from Google I/O 2023 → https://goo.gle/IO23_sessions\nWatch more Mobile Sessions → https://goo.gle/IO23_mobile\nWatch more Web Sessions → https://goo.gle/IO23_web\nAll Google I/O 2023 Sessions → https://goo.gle/IO23_all\n\nSubscribe to Google Developers → https://goo.gle/developers\n\n#GoogleIO", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/c0mqBuXPrpA/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/c0mqBuXPrpA/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/c0mqBuXPrpA/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/c0mqBuXPrpA/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/c0mqBuXPrpA/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "Google for Developers", + "playlistId": "UU_x5XG1OV2P6uZZ5FSM9Ttw", + "position": 3, + "resourceId": { + "kind": "youtube#video", + "videoId": "c0mqBuXPrpA" + }, + "videoOwnerChannelTitle": "Google for Developers", + "videoOwnerChannelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw" + }, + "contentDetails": { + "videoId": "c0mqBuXPrpA", + "videoPublishedAt": "2023-05-10T20:47:57Z" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "--gb8pSHDwp9c-fyjhZ0K2DklLE", + "id": "VVVfeDVYRzFPVjJQNnVaWjVGU005VHR3Ll9uOXh3dVRPUmFz", + "snippet": { + "publishedAt": "2023-05-10T20:46:29Z", + "channelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw", + "title": "What's new in Google Home - American Sign Language", + "description": "To watch this Session without American Sign Language (ASL) interpretation, please click here → https://goo.gle/IO23_homekey\n\nDiscover how your connected devices can do more with Google Home using Matter and Automations.\n\nResources:\nGoogle Home Developer Center → https://goo.gle/3KcD5xr\n\nDiscover how your connected devices can do more with Google Home using Matter and Automations\nGoogle Home APIs Developer Preview → https://goo.gle/3UakRl0\nAutomations Developer Preview → https://goo.gle/3KgEcMy\n\nSpeakers: Taylor Lehman, Indu Ramamurthi\n\nWatch more:\nWatch more Mobile Sessions → https://goo.gle/IO23_mobile\nAll Google I/O 2023 Sessions → https://goo.gle/IO23_all\n\nSubscribe to Google Developers → https://goo.gle/developers\n\n#GoogleIO", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/_n9xwuTORas/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/_n9xwuTORas/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/_n9xwuTORas/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/_n9xwuTORas/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/_n9xwuTORas/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "Google for Developers", + "playlistId": "UU_x5XG1OV2P6uZZ5FSM9Ttw", + "position": 4, + "resourceId": { + "kind": "youtube#video", + "videoId": "_n9xwuTORas" + }, + "videoOwnerChannelTitle": "Google for Developers", + "videoOwnerChannelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw" + }, + "contentDetails": { + "videoId": "_n9xwuTORas", + "videoPublishedAt": "2023-05-10T20:46:29Z" + } + } + ], + "pageInfo": { + "totalResults": 5798, + "resultsPerPage": 5 + } +} diff --git a/tests/components/youtube/test_sensor.py b/tests/components/youtube/test_sensor.py index 1363a4468a7b..3462e291af8f 100644 --- a/tests/components/youtube/test_sensor.py +++ b/tests/components/youtube/test_sensor.py @@ -9,9 +9,11 @@ from homeassistant.components.youtube import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util -from ...common import async_fire_time_changed +from . import MockService from .conftest import TOKEN, ComponentSetup +from tests.common import async_fire_time_changed + async def test_sensor(hass: HomeAssistant, setup_integration: ComponentSetup) -> None: """Test sensor.""" @@ -37,6 +39,36 @@ async def test_sensor(hass: HomeAssistant, setup_integration: ComponentSetup) -> ) +async def test_sensor_updating( + hass: HomeAssistant, setup_integration: ComponentSetup +) -> None: + """Test updating sensor.""" + await setup_integration() + + state = hass.states.get("sensor.google_for_developers_latest_upload") + assert state + assert state.attributes["video_id"] == "wysukDrMdqU" + + with patch( + "homeassistant.components.youtube.api.build", + return_value=MockService( + playlist_items_fixture="youtube/get_playlist_items_2.json" + ), + ): + future = dt_util.utcnow() + timedelta(minutes=15) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + state = hass.states.get("sensor.google_for_developers_latest_upload") + assert state + assert state.name == "Google for Developers Latest upload" + assert state.state == "Google I/O 2023 Developer Keynote in 5 minutes" + assert ( + state.attributes["entity_picture"] + == "https://i.ytimg.com/vi/hleLlcHwQLM/sddefault.jpg" + ) + assert state.attributes["video_id"] == "hleLlcHwQLM" + + async def test_sensor_reauth_trigger( hass: HomeAssistant, setup_integration: ComponentSetup ) -> None: From aff4d537a7cba673f144b709945a43f4b9967ad7 Mon Sep 17 00:00:00 2001 From: Ernst Klamer Date: Mon, 5 Jun 2023 02:07:37 +0200 Subject: [PATCH 788/822] Bump xiaomi-ble to 0.17.2 (#94011) Bump xiaomi-ble Co-authored-by: J. Nick Koston --- homeassistant/components/xiaomi_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/xiaomi_ble/test_binary_sensor.py | 4 ++-- tests/components/xiaomi_ble/test_config_flow.py | 12 ++++++------ 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/xiaomi_ble/manifest.json b/homeassistant/components/xiaomi_ble/manifest.json index 4d5cddd95172..69a95ea8a9c4 100644 --- a/homeassistant/components/xiaomi_ble/manifest.json +++ b/homeassistant/components/xiaomi_ble/manifest.json @@ -16,5 +16,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/xiaomi_ble", "iot_class": "local_push", - "requirements": ["xiaomi-ble==0.17.0"] + "requirements": ["xiaomi-ble==0.17.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index f3b05058c8ff..67c3b34656d5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2677,7 +2677,7 @@ wyoming==0.0.1 xbox-webapi==2.0.11 # homeassistant.components.xiaomi_ble -xiaomi-ble==0.17.0 +xiaomi-ble==0.17.2 # homeassistant.components.knx xknx==2.10.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bd35ab613414..f278b20011e4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1947,7 +1947,7 @@ wyoming==0.0.1 xbox-webapi==2.0.11 # homeassistant.components.xiaomi_ble -xiaomi-ble==0.17.0 +xiaomi-ble==0.17.2 # homeassistant.components.knx xknx==2.10.0 diff --git a/tests/components/xiaomi_ble/test_binary_sensor.py b/tests/components/xiaomi_ble/test_binary_sensor.py index 9345660f21c2..235be5c6cd8d 100644 --- a/tests/components/xiaomi_ble/test_binary_sensor.py +++ b/tests/components/xiaomi_ble/test_binary_sensor.py @@ -253,10 +253,10 @@ async def test_smoke(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert len(hass.states.async_all()) == 1 - smoke_sensor = hass.states.get("binary_sensor.thermometer_9cbc_smoke") + smoke_sensor = hass.states.get("binary_sensor.smoke_detector_9cbc_smoke") smoke_sensor_attribtes = smoke_sensor.attributes assert smoke_sensor.state == STATE_ON - assert smoke_sensor_attribtes[ATTR_FRIENDLY_NAME] == "Thermometer 9CBC Smoke" + assert smoke_sensor_attribtes[ATTR_FRIENDLY_NAME] == "Smoke Detector 9CBC Smoke" assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/xiaomi_ble/test_config_flow.py b/tests/components/xiaomi_ble/test_config_flow.py index 3f537c2afa0d..97aa878e1fb0 100644 --- a/tests/components/xiaomi_ble/test_config_flow.py +++ b/tests/components/xiaomi_ble/test_config_flow.py @@ -248,7 +248,7 @@ async def test_async_step_bluetooth_valid_device_v4_encryption( ) assert result2["type"] == FlowResultType.CREATE_ENTRY - assert result2["title"] == "Thermometer 9CBC (JTYJGD03MI)" + assert result2["title"] == "Smoke Detector 9CBC (JTYJGD03MI)" assert result2["data"] == {"bindkey": "5b51a7c91cde6707c9ef18dfda143a58"} assert result2["result"].unique_id == "54:EF:44:E3:9C:BC" @@ -284,7 +284,7 @@ async def test_async_step_bluetooth_valid_device_v4_encryption_wrong_key( ) assert result2["type"] == FlowResultType.CREATE_ENTRY - assert result2["title"] == "Thermometer 9CBC (JTYJGD03MI)" + assert result2["title"] == "Smoke Detector 9CBC (JTYJGD03MI)" assert result2["data"] == {"bindkey": "5b51a7c91cde6707c9ef18dfda143a58"} assert result2["result"].unique_id == "54:EF:44:E3:9C:BC" @@ -320,7 +320,7 @@ async def test_async_step_bluetooth_valid_device_v4_encryption_wrong_key_length( ) assert result2["type"] == FlowResultType.CREATE_ENTRY - assert result2["title"] == "Thermometer 9CBC (JTYJGD03MI)" + assert result2["title"] == "Smoke Detector 9CBC (JTYJGD03MI)" assert result2["data"] == {"bindkey": "5b51a7c91cde6707c9ef18dfda143a58"} assert result2["result"].unique_id == "54:EF:44:E3:9C:BC" @@ -501,7 +501,7 @@ async def test_async_step_user_with_found_devices_v4_encryption( ) assert result2["type"] == FlowResultType.CREATE_ENTRY - assert result2["title"] == "Thermometer 9CBC (JTYJGD03MI)" + assert result2["title"] == "Smoke Detector 9CBC (JTYJGD03MI)" assert result2["data"] == {"bindkey": "5b51a7c91cde6707c9ef18dfda143a58"} assert result2["result"].unique_id == "54:EF:44:E3:9C:BC" @@ -549,7 +549,7 @@ async def test_async_step_user_with_found_devices_v4_encryption_wrong_key( ) assert result2["type"] == FlowResultType.CREATE_ENTRY - assert result2["title"] == "Thermometer 9CBC (JTYJGD03MI)" + assert result2["title"] == "Smoke Detector 9CBC (JTYJGD03MI)" assert result2["data"] == {"bindkey": "5b51a7c91cde6707c9ef18dfda143a58"} assert result2["result"].unique_id == "54:EF:44:E3:9C:BC" @@ -599,7 +599,7 @@ async def test_async_step_user_with_found_devices_v4_encryption_wrong_key_length ) assert result2["type"] == FlowResultType.CREATE_ENTRY - assert result2["title"] == "Thermometer 9CBC (JTYJGD03MI)" + assert result2["title"] == "Smoke Detector 9CBC (JTYJGD03MI)" assert result2["data"] == {"bindkey": "5b51a7c91cde6707c9ef18dfda143a58"} assert result2["result"].unique_id == "54:EF:44:E3:9C:BC" From 902bd521d275beecc1221a2b3d54a1d63348ba64 Mon Sep 17 00:00:00 2001 From: tronikos Date: Sun, 4 Jun 2023 01:49:18 -0700 Subject: [PATCH 789/822] Android TV Remote: Abort zeroconf if mac address is missing (#94026) Abort zeroconf if mac address is missing --- .../androidtv_remote/config_flow.py | 3 ++- .../androidtv_remote/test_config_flow.py | 24 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/androidtv_remote/config_flow.py b/homeassistant/components/androidtv_remote/config_flow.py index 24b64c622a99..f7e1078d3fa6 100644 --- a/homeassistant/components/androidtv_remote/config_flow.py +++ b/homeassistant/components/androidtv_remote/config_flow.py @@ -135,7 +135,8 @@ class AndroidTVRemoteConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self.host = discovery_info.host self.name = discovery_info.name.removesuffix("._androidtvremote2._tcp.local.") self.mac = discovery_info.properties.get("bt") - assert self.mac + if not self.mac: + return self.async_abort(reason="cannot_connect") await self.async_set_unique_id(format_mac(self.mac)) self._abort_if_unique_id_configured( updates={CONF_HOST: self.host, CONF_NAME: self.name} diff --git a/tests/components/androidtv_remote/test_config_flow.py b/tests/components/androidtv_remote/test_config_flow.py index ea1f4abfc1db..ec368081a95d 100644 --- a/tests/components/androidtv_remote/test_config_flow.py +++ b/tests/components/androidtv_remote/test_config_flow.py @@ -712,6 +712,30 @@ async def test_zeroconf_flow_already_configured_host_not_changed_no_reload_entry assert len(mock_setup_entry.mock_calls) == 0 +async def test_zeroconf_flow_abort_if_mac_is_missing( + hass: HomeAssistant, +) -> None: + """Test when mac is missing in the zeroconf discovery we abort.""" + host = "1.2.3.4" + name = "My Android TV" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + host=host, + addresses=[host], + port=6466, + hostname=host, + type="mock_type", + name=name + "._androidtvremote2._tcp.local.", + properties={}, + ), + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + async def test_reauth_flow_success( hass: HomeAssistant, mock_setup_entry: AsyncMock, From 5a63079c808e49b61025d9a91cebe59293a6f565 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 4 Jun 2023 20:53:34 +0200 Subject: [PATCH 790/822] Remove update_before_add from binary_sensor in Command Line (#94040) Remove update_before_add --- homeassistant/components/command_line/binary_sensor.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/command_line/binary_sensor.py b/homeassistant/components/command_line/binary_sensor.py index 9c5a1ce1bbeb..8abe401ec9ce 100644 --- a/homeassistant/components/command_line/binary_sensor.py +++ b/homeassistant/components/command_line/binary_sensor.py @@ -107,7 +107,6 @@ async def async_setup_platform( scan_interval, ) ], - True, ) From 4a31cb0ad8dcdff7700815aebec805cec9d8d1b4 Mon Sep 17 00:00:00 2001 From: Pascal Reeb Date: Mon, 5 Jun 2023 02:06:38 +0200 Subject: [PATCH 791/822] Update pynuki to 1.6.2 (#94041) chore(component/nuki): update pynuki to 1.6.2 --- homeassistant/components/nuki/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nuki/manifest.json b/homeassistant/components/nuki/manifest.json index 8b87816fb7d0..b84bee660c1b 100644 --- a/homeassistant/components/nuki/manifest.json +++ b/homeassistant/components/nuki/manifest.json @@ -12,5 +12,5 @@ "documentation": "https://www.home-assistant.io/integrations/nuki", "iot_class": "local_polling", "loggers": ["pynuki"], - "requirements": ["pynuki==1.6.1"] + "requirements": ["pynuki==1.6.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 67c3b34656d5..f2038c111fb6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1831,7 +1831,7 @@ pynina==0.3.0 pynobo==1.6.0 # homeassistant.components.nuki -pynuki==1.6.1 +pynuki==1.6.2 # homeassistant.components.nut pynut2==2.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f278b20011e4..44419f528283 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1347,7 +1347,7 @@ pynina==0.3.0 pynobo==1.6.0 # homeassistant.components.nuki -pynuki==1.6.1 +pynuki==1.6.2 # homeassistant.components.nut pynut2==2.1.2 From 580065e946ff40375a93480b71ecc20632a212a0 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Sun, 4 Jun 2023 18:35:17 -0400 Subject: [PATCH 792/822] Fix zwave_js.update entity restore logic (#94043) --- homeassistant/components/zwave_js/update.py | 25 ++++++++------ tests/components/zwave_js/test_update.py | 36 +++++++++++++++++++++ 2 files changed, 51 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/zwave_js/update.py b/homeassistant/components/zwave_js/update.py index 8403e28a68b5..5b7c157552ad 100644 --- a/homeassistant/components/zwave_js/update.py +++ b/homeassistant/components/zwave_js/update.py @@ -42,6 +42,7 @@ PARALLEL_UPDATES = 1 UPDATE_DELAY_STRING = "delay" UPDATE_DELAY_INTERVAL = 5 # In minutes +ATTR_LATEST_VERSION_FIRMWARE = "latest_version_firmware" @dataclass @@ -53,7 +54,7 @@ class ZWaveNodeFirmwareUpdateExtraStoredData(ExtraStoredData): def as_dict(self) -> dict[str, Any]: """Return a dict representation of the extra data.""" return { - "latest_version_firmware": asdict(self.latest_version_firmware) + ATTR_LATEST_VERSION_FIRMWARE: asdict(self.latest_version_firmware) if self.latest_version_firmware else None } @@ -61,7 +62,7 @@ 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["latest_version_firmware"]): + if not (firmware_dict := data[ATTR_LATEST_VERSION_FIRMWARE]): return cls(None) return cls(NodeFirmwareUpdateInfo.from_dict(firmware_dict)) @@ -326,20 +327,24 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity): ) # If we have a complete previous state, use that to set the latest version - if (state := await self.async_get_last_state()) and ( - extra_data := await self.async_get_last_extra_data() + if ( + (state := await self.async_get_last_state()) + and (latest_version := state.attributes.get(ATTR_LATEST_VERSION)) + is not None + and (extra_data := await self.async_get_last_extra_data()) ): - self._attr_latest_version = state.attributes[ATTR_LATEST_VERSION] + self._attr_latest_version = latest_version self._latest_version_firmware = ( ZWaveNodeFirmwareUpdateExtraStoredData.from_dict( extra_data.as_dict() ).latest_version_firmware ) - # If we have no state to restore, we can set the latest version to installed - # so that the entity starts as off. If we have partial restore data due to an - # upgrade to an HA version where this feature is released from one that is not - # the entity will start in an unknown state until we can correct on next update - elif not state: + # If we have no state or latest version to restore, we can set the latest + # version to installed so that the entity starts as off. If we have partial + # restore data due to an upgrade to an HA version where this feature is released + # from one that is not the entity will start in an unknown state until we can + # correct on next update + elif not state or not latest_version: self._attr_latest_version = self._attr_installed_version # Spread updates out in 5 minute increments to avoid flooding the network diff --git a/tests/components/zwave_js/test_update.py b/tests/components/zwave_js/test_update.py index 1a783f06bea6..6a8cbdd724a1 100644 --- a/tests/components/zwave_js/test_update.py +++ b/tests/components/zwave_js/test_update.py @@ -778,6 +778,42 @@ async def test_update_entity_full_restore_data_no_update_available( assert state.attributes[ATTR_LATEST_VERSION] == "10.7" +async def test_update_entity_no_latest_version( + hass: HomeAssistant, + client, + climate_radio_thermostat_ct100_plus_different_endpoints, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test entity with no `latest_version` attr restores state.""" + mock_restore_cache_with_extra_data( + hass, + [ + ( + State( + UPDATE_ENTITY, + STATE_OFF, + { + ATTR_INSTALLED_VERSION: "10.7", + ATTR_LATEST_VERSION: None, + ATTR_SKIPPED_VERSION: None, + }, + ), + {"latest_version_firmware": None}, + ) + ], + ) + 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() + + state = hass.states.get(UPDATE_ENTITY) + assert state + assert state.state == STATE_OFF + assert state.attributes[ATTR_SKIPPED_VERSION] is None + assert state.attributes[ATTR_LATEST_VERSION] == "10.7" + + async def test_update_entity_unload_asleep_node( hass: HomeAssistant, client, wallmote_central_scene, integration ) -> None: From dbd5511e5e5f23bfcae9f45efdac236391389aca Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 4 Jun 2023 21:14:44 -0500 Subject: [PATCH 793/822] Bump zeroconf to 0.64.0 (#94052) --- 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 442f32974678..85cf503bb0d6 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.63.0"] + "requirements": ["zeroconf==0.64.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 17efae774983..80e1a95ba9f7 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -52,7 +52,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtcvad==2.0.10 yarl==1.9.2 -zeroconf==0.63.0 +zeroconf==0.64.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 f2038c111fb6..252db35f280e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2728,7 +2728,7 @@ zamg==0.2.2 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.63.0 +zeroconf==0.64.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 44419f528283..b837e61dc478 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1986,7 +1986,7 @@ youless-api==1.0.1 zamg==0.2.2 # homeassistant.components.zeroconf -zeroconf==0.63.0 +zeroconf==0.64.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From 4bb6fec1d6ad7b893617db4a7d479785b8ea4dfe Mon Sep 17 00:00:00 2001 From: Luke Date: Mon, 5 Jun 2023 15:52:40 -0400 Subject: [PATCH 794/822] Don't add Roborock switches if it is not supported (#94069) * don't add switches if it is not supported * don't create entity unless if it is valid * Raise on other exceptions * rework valid_enties --- homeassistant/components/roborock/switch.py | 52 +++++++++++++++++---- 1 file changed, 43 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/roborock/switch.py b/homeassistant/components/roborock/switch.py index 6971ff9d9003..d8ff50430cbc 100644 --- a/homeassistant/components/roborock/switch.py +++ b/homeassistant/components/roborock/switch.py @@ -1,9 +1,11 @@ """Support for Roborock switch.""" +import asyncio from collections.abc import Callable, Coroutine from dataclasses import dataclass import logging from typing import Any +from roborock.exceptions import RoborockException from roborock.roborock_typing import RoborockCommand from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription @@ -30,6 +32,8 @@ class RoborockSwitchDescriptionMixin: evaluate_value: Callable[[dict], bool] # Sets the status of the switch set_command: Callable[[RoborockEntity, bool], Coroutine[Any, Any, dict]] + # Check support of this feature + check_support: Callable[[RoborockDataUpdateCoordinator], Coroutine[Any, Any, dict]] @dataclass @@ -45,6 +49,9 @@ SWITCH_DESCRIPTIONS: list[RoborockSwitchDescription] = [ RoborockCommand.SET_CHILD_LOCK_STATUS, {"lock_status": 1 if value else 0} ), get_value=lambda data: data.send(RoborockCommand.GET_CHILD_LOCK_STATUS), + check_support=lambda data: data.api.send_command( + RoborockCommand.GET_CHILD_LOCK_STATUS + ), evaluate_value=lambda data: data["lock_status"] == 1, key="child_lock", translation_key="child_lock", @@ -56,6 +63,9 @@ SWITCH_DESCRIPTIONS: list[RoborockSwitchDescription] = [ RoborockCommand.SET_FLOW_LED_STATUS, {"status": 1 if value else 0} ), get_value=lambda data: data.send(RoborockCommand.GET_FLOW_LED_STATUS), + check_support=lambda data: data.api.send_command( + RoborockCommand.GET_FLOW_LED_STATUS + ), evaluate_value=lambda data: data["status"] == 1, key="status_indicator", translation_key="status_indicator", @@ -75,16 +85,38 @@ async def async_setup_entry( coordinators: dict[str, RoborockDataUpdateCoordinator] = hass.data[DOMAIN][ config_entry.entry_id ] - async_add_entities( - ( - RoborockSwitchEntity( - f"{description.key}_{slugify(device_id)}", - coordinator, - description, - ) - for device_id, coordinator in coordinators.items() - for description in SWITCH_DESCRIPTIONS + possible_entities: list[ + tuple[str, RoborockDataUpdateCoordinator, RoborockSwitchDescription] + ] = [ + (device_id, coordinator, description) + for device_id, coordinator in coordinators.items() + for description in SWITCH_DESCRIPTIONS + ] + # We need to check if this function is supported by the device. + results = await asyncio.gather( + *( + description.check_support(coordinator) + for _, coordinator, description in possible_entities ), + return_exceptions=True, + ) + valid_entities: list[RoborockSwitchEntity] = [] + for posible_entity, result in zip(possible_entities, results): + if isinstance(result, Exception): + if not isinstance(result, RoborockException): + raise result + _LOGGER.debug("Not adding entity because of %s", result) + else: + valid_entities.append( + RoborockSwitchEntity( + f"{posible_entity[2].key}_{slugify(posible_entity[0])}", + posible_entity[1], + posible_entity[2], + result, + ) + ) + async_add_entities( + valid_entities, True, ) @@ -99,10 +131,12 @@ class RoborockSwitchEntity(RoborockEntity, SwitchEntity): unique_id: str, coordinator: RoborockDataUpdateCoordinator, entity_description: RoborockSwitchDescription, + initial_value: bool, ) -> None: """Create a switch entity.""" self.entity_description = entity_description super().__init__(unique_id, coordinator.device_info, coordinator.api) + self._attr_is_on = initial_value async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the switch.""" From eb036af41066b874210691ccbfe8a795a2cd788c Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Mon, 5 Jun 2023 12:49:01 -0500 Subject: [PATCH 795/822] Bump intents to 2023.6.5 (#94077) --- 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 06666af815aa..01276d56081a 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["hassil==1.0.6", "home-assistant-intents==2023.5.30"] + "requirements": ["hassil==1.0.6", "home-assistant-intents==2023.6.5"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 80e1a95ba9f7..0e568b7d908f 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -26,7 +26,7 @@ hass-nabucasa==0.67.1 hassil==1.0.6 home-assistant-bluetooth==1.10.0 home-assistant-frontend==20230601.1 -home-assistant-intents==2023.5.30 +home-assistant-intents==2023.6.5 httpx==0.24.1 ifaddr==0.2.0 janus==1.0.0 diff --git a/requirements_all.txt b/requirements_all.txt index 252db35f280e..7d3aa7eb86ee 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -927,7 +927,7 @@ holidays==0.21.13 home-assistant-frontend==20230601.1 # homeassistant.components.conversation -home-assistant-intents==2023.5.30 +home-assistant-intents==2023.6.5 # homeassistant.components.home_connect homeconnect==0.7.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b837e61dc478..a843f7b99961 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -719,7 +719,7 @@ holidays==0.21.13 home-assistant-frontend==20230601.1 # homeassistant.components.conversation -home-assistant-intents==2023.5.30 +home-assistant-intents==2023.6.5 # homeassistant.components.home_connect homeconnect==0.7.2 From 28e0f5e104c7fbb6f5b0ea83945782cac66ea85f Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 5 Jun 2023 20:16:11 +0200 Subject: [PATCH 796/822] Update frontend to 20230605.0 (#94083) Co-authored-by: Paulus Schoutsen --- 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 838294f7ba5b..b82ece333156 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==20230601.1"] + "requirements": ["home-assistant-frontend==20230605.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 0e568b7d908f..1fe097ed1ac1 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -25,7 +25,7 @@ ha-av==10.1.0 hass-nabucasa==0.67.1 hassil==1.0.6 home-assistant-bluetooth==1.10.0 -home-assistant-frontend==20230601.1 +home-assistant-frontend==20230605.0 home-assistant-intents==2023.6.5 httpx==0.24.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 7d3aa7eb86ee..0ed2cb081132 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -924,7 +924,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230601.1 +home-assistant-frontend==20230605.0 # homeassistant.components.conversation home-assistant-intents==2023.6.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a843f7b99961..bc6bf1e9e72a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -716,7 +716,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230601.1 +home-assistant-frontend==20230605.0 # homeassistant.components.conversation home-assistant-intents==2023.6.5 From ee8f63b9c95de984a5b271b247f22b2d38a66354 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 5 Jun 2023 21:54:51 +0200 Subject: [PATCH 797/822] Fix reload service in Command Line (#94085) Fix multi platform reload service in command line --- homeassistant/components/command_line/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/command_line/__init__.py b/homeassistant/components/command_line/__init__.py index c9c18fe54a80..906e28052da5 100644 --- a/homeassistant/components/command_line/__init__.py +++ b/homeassistant/components/command_line/__init__.py @@ -173,7 +173,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: platforms: list[Platform] = [] for platform_config in command_line_config: for platform, _config in platform_config.items(): - platforms.append(PLATFORM_MAPPING[platform]) + if (mapped_platform := PLATFORM_MAPPING[platform]) not in platforms: + platforms.append(mapped_platform) _LOGGER.debug( "Loading config %s for platform %s", platform_config, From 7a6327d7e29984742cf4237bf05a93608277ea62 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 5 Jun 2023 16:13:07 -0400 Subject: [PATCH 798/822] Bumped version to 2023.6.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 638a4afe037f..8b872c061c98 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -8,7 +8,7 @@ from .backports.enum import StrEnum APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 MINOR_VERSION: Final = 6 -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, 10, 0) diff --git a/pyproject.toml b/pyproject.toml index 006692082b59..29411420d42f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.6.0b4" +version = "2023.6.0b5" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 2c43672a8ad676e6c9f3da4d5f2451c366ac52ab Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Thu, 1 Jun 2023 13:53:41 -0400 Subject: [PATCH 799/822] Include port info in the ZHA websocket settings response (#93934) --- homeassistant/components/zha/websocket_api.py | 2 ++ tests/components/zha/test_websocket_api.py | 1 + 2 files changed, 3 insertions(+) diff --git a/homeassistant/components/zha/websocket_api.py b/homeassistant/components/zha/websocket_api.py index 019a5c50238a..28e115c0ec4f 100644 --- a/homeassistant/components/zha/websocket_api.py +++ b/homeassistant/components/zha/websocket_api.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Any, Literal, NamedTuple, TypeVar, cast import voluptuous as vol import zigpy.backups +from zigpy.config import CONF_DEVICE from zigpy.config.validators import cv_boolean from zigpy.types.named import EUI64 from zigpy.zcl.clusters.security import IasAce @@ -1136,6 +1137,7 @@ async def websocket_get_network_settings( msg[ID], { "radio_type": async_get_radio_type(hass, zha_gateway.config_entry).name, + "device": zha_gateway.application_controller.config[CONF_DEVICE], "settings": backup.as_dict(), }, ) diff --git a/tests/components/zha/test_websocket_api.py b/tests/components/zha/test_websocket_api.py index 720cfaaac9b2..5250b62a9b0b 100644 --- a/tests/components/zha/test_websocket_api.py +++ b/tests/components/zha/test_websocket_api.py @@ -744,6 +744,7 @@ async def test_get_network_settings( assert msg["success"] assert "radio_type" in msg["result"] assert "network_info" in msg["result"]["settings"] + assert "path" in msg["result"]["device"] async def test_list_network_backups( From f373f1abd53c6a4471ff8c0fa45e1c36a5288976 Mon Sep 17 00:00:00 2001 From: Luke Date: Tue, 6 Jun 2023 02:25:25 -0400 Subject: [PATCH 800/822] Add missing translation keys for Roborock mop intensity (#94088) --- homeassistant/components/roborock/strings.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 1a65b636dfc9..00ebd3833a80 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -90,8 +90,11 @@ "name": "Mop intensity", "state": { "off": "Off", + "low": "Low", "mild": "Mild", + "medium": "Medium", "moderate": "Moderate", + "high": "High", "intense": "Intense", "custom": "Custom" } From e00012289d9a5b0bb8840226d99d512ad737e9f1 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Tue, 6 Jun 2023 09:08:17 +0200 Subject: [PATCH 801/822] Bump aiounifi to v48 - Fix fail to initialise due to board_rev not exist (#94093) --- 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 f43e30309163..f48191e471a1 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==47"], + "requirements": ["aiounifi==48"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 0ed2cb081132..b42f634c5069 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -294,7 +294,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.5 # homeassistant.components.unifi -aiounifi==47 +aiounifi==48 # homeassistant.components.vlc_telnet aiovlc==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bc6bf1e9e72a..0fd0fb8db9f8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -275,7 +275,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.5 # homeassistant.components.unifi -aiounifi==47 +aiounifi==48 # homeassistant.components.vlc_telnet aiovlc==0.1.0 From e6fcc6b73c04a567ea697bc9d09258290444b92d Mon Sep 17 00:00:00 2001 From: Justin Vanderhooft Date: Tue, 6 Jun 2023 07:32:07 +0100 Subject: [PATCH 802/822] fix: Bump melnor-bluetooth to fix deadlock (#94098) --- homeassistant/components/melnor/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/melnor/manifest.json b/homeassistant/components/melnor/manifest.json index 87a5583fa4fd..185899a96564 100644 --- a/homeassistant/components/melnor/manifest.json +++ b/homeassistant/components/melnor/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/melnor", "iot_class": "local_polling", - "requirements": ["melnor-bluetooth==0.0.22"] + "requirements": ["melnor-bluetooth==0.0.24"] } diff --git a/requirements_all.txt b/requirements_all.txt index b42f634c5069..ad022200384c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1122,7 +1122,7 @@ mcstatus==6.0.0 meater-python==0.0.8 # homeassistant.components.melnor -melnor-bluetooth==0.0.22 +melnor-bluetooth==0.0.24 # homeassistant.components.message_bird messagebird==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0fd0fb8db9f8..f28a2b43ba54 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -848,7 +848,7 @@ mcstatus==6.0.0 meater-python==0.0.8 # homeassistant.components.melnor -melnor-bluetooth==0.0.22 +melnor-bluetooth==0.0.24 # homeassistant.components.meteo_france meteofrance-api==1.2.0 From 49388eab3ae4affc4d8a2d6915a7d46997f0cddb Mon Sep 17 00:00:00 2001 From: Luke Date: Tue, 6 Jun 2023 21:24:36 -0400 Subject: [PATCH 803/822] Add diagnostics to Roborock (#94099) * Add diagnostics * Update homeassistant/components/roborock/models.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * adds snapshot --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .../components/roborock/diagnostics.py | 38 +++ homeassistant/components/roborock/models.py | 10 + tests/components/roborock/conftest.py | 5 +- tests/components/roborock/mock_data.py | 4 + .../roborock/snapshots/test_diagnostics.ambr | 303 ++++++++++++++++++ tests/components/roborock/test_diagnostics.py | 23 ++ 6 files changed, 381 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/roborock/diagnostics.py create mode 100644 tests/components/roborock/snapshots/test_diagnostics.ambr create mode 100644 tests/components/roborock/test_diagnostics.py diff --git a/homeassistant/components/roborock/diagnostics.py b/homeassistant/components/roborock/diagnostics.py new file mode 100644 index 000000000000..e5fcc8342677 --- /dev/null +++ b/homeassistant/components/roborock/diagnostics.py @@ -0,0 +1,38 @@ +"""Support for the Airzone diagnostics.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics.util import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_UNIQUE_ID +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import RoborockDataUpdateCoordinator + +TO_REDACT_CONFIG = ["token", "sn", "rruid", CONF_UNIQUE_ID, "username", "uid"] + +TO_REDACT_COORD = ["duid", "localKey", "mac", "bssid"] + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinators: dict[str, RoborockDataUpdateCoordinator] = hass.data[DOMAIN][ + config_entry.entry_id + ] + + return { + "config_entry": async_redact_data(config_entry.data, TO_REDACT_CONFIG), + "coordinators": { + f"**REDACTED-{i}**": { + "roborock_device_info": async_redact_data( + coordinator.roborock_device_info.as_dict(), TO_REDACT_COORD + ), + "api": coordinator.api.diagnostic_data, + } + for i, coordinator in enumerate(coordinators.values()) + }, + } diff --git a/homeassistant/components/roborock/models.py b/homeassistant/components/roborock/models.py index a30c84ce1da4..c1d32df2d6d3 100644 --- a/homeassistant/components/roborock/models.py +++ b/homeassistant/components/roborock/models.py @@ -1,5 +1,6 @@ """Roborock Models.""" from dataclasses import dataclass +from typing import Any from roborock.containers import HomeDataDevice, HomeDataProduct, NetworkInfo from roborock.roborock_typing import DeviceProp @@ -13,3 +14,12 @@ class RoborockHassDeviceInfo: network_info: NetworkInfo product: HomeDataProduct props: DeviceProp + + def as_dict(self) -> dict[str, dict[str, Any]]: + """Turn RoborockHassDeviceInfo into a dictionary.""" + return { + "device": self.device.as_dict(), + "network_info": self.network_info.as_dict(), + "product": self.product.as_dict(), + "props": self.props.as_dict(), + } diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index b311f84f94a0..d9c11bead74a 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -12,7 +12,7 @@ from homeassistant.const import CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from .mock_data import BASE_URL, HOME_DATA, PROP, USER_DATA, USER_EMAIL +from .mock_data import BASE_URL, HOME_DATA, NETWORK_INFO, PROP, USER_DATA, USER_EMAIL from tests.common import MockConfigEntry @@ -54,7 +54,8 @@ async def setup_entry( "homeassistant.components.roborock.RoborockApiClient.get_home_data", return_value=HOME_DATA, ), patch( - "homeassistant.components.roborock.RoborockMqttClient.get_networking" + "homeassistant.components.roborock.RoborockMqttClient.get_networking", + return_value=NETWORK_INFO, ), patch( "homeassistant.components.roborock.coordinator.RoborockLocalClient.get_prop", return_value=PROP, diff --git a/tests/components/roborock/mock_data.py b/tests/components/roborock/mock_data.py index 15e69cee9d9d..8155c10fdbd5 100644 --- a/tests/components/roborock/mock_data.py +++ b/tests/components/roborock/mock_data.py @@ -1,6 +1,8 @@ """Mock data for Roborock tests.""" from __future__ import annotations +import datetime + from roborock.containers import ( CleanRecord, CleanSummary, @@ -320,6 +322,8 @@ DND_TIMER = DnDTimer.from_dict( "enabled": 1, } ) +DND_TIMER.start_time = datetime.datetime(year=2023, month=6, day=1, hour=22) +DND_TIMER.end_time = datetime.datetime(year=2023, month=6, day=2, hour=7) STATUS = S7Status.from_dict( { diff --git a/tests/components/roborock/snapshots/test_diagnostics.ambr b/tests/components/roborock/snapshots/test_diagnostics.ambr new file mode 100644 index 000000000000..5cb9b1093684 --- /dev/null +++ b/tests/components/roborock/snapshots/test_diagnostics.ambr @@ -0,0 +1,303 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'config_entry': dict({ + 'base_url': 'https://usiot.roborock.com', + 'user_data': dict({ + 'avatarurl': 'https://files.roborock.com/iottest/default_avatar.png', + 'country': 'US', + 'countrycode': '1', + 'nickname': 'user_nickname', + 'region': 'us', + 'rriot': dict({ + 'h': 'abc123', + 'k': 'abc123', + 'r': dict({ + 'a': 'https://api-us.roborock.com', + 'l': 'https://wood-us.roborock.com', + 'm': 'ssl://mqtt-us-2.roborock.com:8883', + 'r': 'US', + }), + 's': 'abc123', + 'u': 'abc123', + }), + 'rruid': '**REDACTED**', + 'token': '**REDACTED**', + 'tokentype': '', + 'tuyaDeviceState': 2, + 'uid': '**REDACTED**', + }), + 'username': '**REDACTED**', + }), + 'coordinators': dict({ + '**REDACTED-0**': 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 MaxV', + 'newFeatureSet': '0000000000002041', + 'online': True, + 'productId': 'abc123', + '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': 'abc123', + '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, + }), + 'dndTimer': dict({ + 'enabled': 1, + 'endHour': 7, + 'endMinute': 0, + 'endTime': '2023-06-02T07:00:00', + 'startHour': 22, + 'startMinute': 0, + 'startTime': '2023-06-01T22:00:00', + }), + 'lastCleanRecord': dict({ + 'area': 20965000, + 'avoidCount': 19, + 'begin': 1672543330, + 'cleanType': 3, + 'complete': 1, + 'duration': 1176, + 'dustCollectionStatus': 1, + 'end': 1672544638, + '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, + }), + }), + }), + }), + }), + }) +# --- diff --git a/tests/components/roborock/test_diagnostics.py b/tests/components/roborock/test_diagnostics.py new file mode 100644 index 000000000000..a10cbcf057eb --- /dev/null +++ b/tests/components/roborock/test_diagnostics.py @@ -0,0 +1,23 @@ +"""Tests for the diagnostics data provided by the Roborock integration.""" + +from syrupy.assertion import SnapshotAssertion + +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 + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + bypass_api_fixture, + setup_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics for config entry.""" + result = await get_diagnostics_for_config_entry(hass, hass_client, setup_entry) + + assert isinstance(result, dict) + assert result == snapshot From 7a658117bb5dd20762d86ccaa1db00e42b169789 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Tue, 6 Jun 2023 08:23:48 +0200 Subject: [PATCH 804/822] Update aioairzone to v0.6.3 and fix issue with latest firmware update (#94100) --- homeassistant/components/airzone/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/airzone/test_climate.py | 3 --- 4 files changed, 3 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/airzone/manifest.json b/homeassistant/components/airzone/manifest.json index 9fbdd95518e6..637066629dbb 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.1"] + "requirements": ["aioairzone==0.6.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index ad022200384c..20417e19eadd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -119,7 +119,7 @@ aioairq==0.2.4 aioairzone-cloud==0.1.7 # homeassistant.components.airzone -aioairzone==0.6.1 +aioairzone==0.6.3 # homeassistant.components.ambient_station aioambient==2023.04.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f28a2b43ba54..39c7507aa975 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -109,7 +109,7 @@ aioairq==0.2.4 aioairzone-cloud==0.1.7 # homeassistant.components.airzone -aioairzone==0.6.1 +aioairzone==0.6.3 # homeassistant.components.ambient_station aioambient==2023.04.0 diff --git a/tests/components/airzone/test_climate.py b/tests/components/airzone/test_climate.py index f51dd3182400..2c66adcb9746 100644 --- a/tests/components/airzone/test_climate.py +++ b/tests/components/airzone/test_climate.py @@ -1,14 +1,12 @@ """The climate tests for the Airzone platform.""" from unittest.mock import patch -from aioairzone.common import OperationMode from aioairzone.const import ( API_COOL_SET_POINT, API_DATA, API_HEAT_SET_POINT, API_MAX_TEMP, API_MIN_TEMP, - API_MODE, API_ON, API_SET_POINT, API_SPEED, @@ -336,7 +334,6 @@ async def test_airzone_climate_set_hvac_mode(hass: HomeAssistant) -> None: { API_SYSTEM_ID: 1, API_ZONE_ID: 1, - API_MODE: OperationMode.COOLING.value, API_ON: 1, } ] From 3e239962472e9f0186f5fe0c80b614c689058130 Mon Sep 17 00:00:00 2001 From: Luke Date: Sun, 4 Jun 2023 20:02:13 -0400 Subject: [PATCH 805/822] Bump Roborock to 0.21.0 (#94035) bump to 21.0 --- 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 44a4cba89c98..41e4a359e2ee 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.17.0"] + "requirements": ["python-roborock==0.21.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 20417e19eadd..3386be032bff 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2121,7 +2121,7 @@ python-qbittorrent==0.4.2 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==0.17.0 +python-roborock==0.21.0 # homeassistant.components.smarttub python-smarttub==0.0.33 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 39c7507aa975..fc89c8f82ad0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1541,7 +1541,7 @@ python-picnic-api==1.1.0 python-qbittorrent==0.4.2 # homeassistant.components.roborock -python-roborock==0.17.0 +python-roborock==0.21.0 # homeassistant.components.smarttub python-smarttub==0.0.33 From 286de1f051bfd85487a129a5883dd41210b8624c Mon Sep 17 00:00:00 2001 From: Luke Date: Tue, 6 Jun 2023 20:42:59 -0400 Subject: [PATCH 806/822] Bump python-roborock to 23.4 (#94111) * bump to 23.0 * bump to 23.4 --- 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 41e4a359e2ee..0cd437278cf7 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.21.0"] + "requirements": ["python-roborock==0.23.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 3386be032bff..0d833845c222 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2121,7 +2121,7 @@ python-qbittorrent==0.4.2 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==0.21.0 +python-roborock==0.23.4 # homeassistant.components.smarttub python-smarttub==0.0.33 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fc89c8f82ad0..00c2152a6485 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1541,7 +1541,7 @@ python-picnic-api==1.1.0 python-qbittorrent==0.4.2 # homeassistant.components.roborock -python-roborock==0.21.0 +python-roborock==0.23.4 # homeassistant.components.smarttub python-smarttub==0.0.33 From 0e50baf0072d4447afaa88ff173936bb5ea427a8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 6 Jun 2023 19:42:11 -0500 Subject: [PATCH 807/822] Verify persistant notifications can be dismissed by the id they are created with (#94112) --- .../persistent_notification/test_init.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/components/persistent_notification/test_init.py b/tests/components/persistent_notification/test_init.py index 7fd8b00d3e1b..1e083814e0e6 100644 --- a/tests/components/persistent_notification/test_init.py +++ b/tests/components/persistent_notification/test_init.py @@ -212,3 +212,27 @@ async def test_ws_get_subscribe( assert msg["event"] event = msg["event"] assert event["type"] == "removed" + + +async def test_manual_notification_id_round_trip(hass: HomeAssistant) -> None: + """Test that a manual notification id can be round tripped.""" + notifications = pn._async_get_or_create_notifications(hass) + assert len(notifications) == 0 + + await hass.services.async_call( + pn.DOMAIN, + "create", + {"notification_id": "synology_diskstation_hub_notification", "message": "test"}, + blocking=True, + ) + + assert len(notifications) == 1 + + await hass.services.async_call( + pn.DOMAIN, + "dismiss", + {"notification_id": "synology_diskstation_hub_notification"}, + blocking=True, + ) + + assert len(notifications) == 0 From 2b39550e5549f32413db57b3fabeef0c761ea170 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 7 Jun 2023 02:04:22 +0200 Subject: [PATCH 808/822] Update frontend to 20230606.0 (#94119) --- 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 b82ece333156..47bdf60b8e78 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==20230605.0"] + "requirements": ["home-assistant-frontend==20230606.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 1fe097ed1ac1..f02f852aa32b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -25,7 +25,7 @@ ha-av==10.1.0 hass-nabucasa==0.67.1 hassil==1.0.6 home-assistant-bluetooth==1.10.0 -home-assistant-frontend==20230605.0 +home-assistant-frontend==20230606.0 home-assistant-intents==2023.6.5 httpx==0.24.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 0d833845c222..10fa122d30c5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -924,7 +924,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230605.0 +home-assistant-frontend==20230606.0 # homeassistant.components.conversation home-assistant-intents==2023.6.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 00c2152a6485..0eac0f7fbce2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -716,7 +716,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230605.0 +home-assistant-frontend==20230606.0 # homeassistant.components.conversation home-assistant-intents==2023.6.5 From 6a573b507e0d55d0c32556411cb9b4262cf9bdb8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 6 Jun 2023 19:40:32 -0500 Subject: [PATCH 809/822] Remove `mark_read` service from persistent_notification (#94122) * Remove mark_read from persistent_notification Nothing on the frontend uses this, and the service is not documented There is not much point in keeping this as the notifications are no longer stored in the state machine * adjust * adjust --- .../persistent_notification/__init__.py | 32 ---------- .../persistent_notification/services.yaml | 12 ---- .../persistent_notification/test_init.py | 61 ------------------- 3 files changed, 105 deletions(-) diff --git a/homeassistant/components/persistent_notification/__init__.py b/homeassistant/components/persistent_notification/__init__.py index 81a9bc9de4d7..fe8849c7788e 100644 --- a/homeassistant/components/persistent_notification/__init__.py +++ b/homeassistant/components/persistent_notification/__init__.py @@ -29,8 +29,6 @@ ATTR_NOTIFICATION_ID: Final = "notification_id" ATTR_TITLE: Final = "title" ATTR_STATUS: Final = "status" -STATUS_UNREAD = "unread" -STATUS_READ = "read" # Remove EVENT_PERSISTENT_NOTIFICATIONS_UPDATED in Home Assistant 2023.9 EVENT_PERSISTENT_NOTIFICATIONS_UPDATED = "persistent_notifications_updated" @@ -43,7 +41,6 @@ class Notification(TypedDict): message: str notification_id: str title: str | None - status: str class UpdateType(StrEnum): @@ -98,7 +95,6 @@ def async_create( notifications[notification_id] = { ATTR_MESSAGE: message, ATTR_NOTIFICATION_ID: notification_id, - ATTR_STATUS: STATUS_UNREAD, ATTR_TITLE: title, ATTR_CREATED_AT: dt_util.utcnow(), } @@ -135,7 +131,6 @@ def async_dismiss(hass: HomeAssistant, notification_id: str) -> None: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the persistent notification component.""" - notifications = _async_get_or_create_notifications(hass) @callback def create_service(call: ServiceCall) -> None: @@ -152,29 +147,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Handle the dismiss notification service call.""" async_dismiss(hass, call.data[ATTR_NOTIFICATION_ID]) - @callback - def mark_read_service(call: ServiceCall) -> None: - """Handle the mark_read notification service call.""" - notification_id = call.data.get(ATTR_NOTIFICATION_ID) - if notification_id not in notifications: - _LOGGER.error( - ( - "Marking persistent_notification read failed: " - "Notification ID %s not found" - ), - notification_id, - ) - return - - notification = notifications[notification_id] - notification[ATTR_STATUS] = STATUS_READ - async_dispatcher_send( - hass, - SIGNAL_PERSISTENT_NOTIFICATIONS_UPDATED, - UpdateType.UPDATED, - {notification_id: notification}, - ) - hass.services.async_register( DOMAIN, "create", @@ -192,10 +164,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: DOMAIN, "dismiss", dismiss_service, SCHEMA_SERVICE_NOTIFICATION ) - hass.services.async_register( - DOMAIN, "mark_read", mark_read_service, SCHEMA_SERVICE_NOTIFICATION - ) - websocket_api.async_register_command(hass, websocket_get_notifications) websocket_api.async_register_command(hass, websocket_subscribe_notifications) diff --git a/homeassistant/components/persistent_notification/services.yaml b/homeassistant/components/persistent_notification/services.yaml index 5695a3c3b820..60dbf5c864ac 100644 --- a/homeassistant/components/persistent_notification/services.yaml +++ b/homeassistant/components/persistent_notification/services.yaml @@ -33,15 +33,3 @@ dismiss: example: 1234 selector: text: - -mark_read: - name: Mark read - description: Mark a notification read. - fields: - notification_id: - name: Notification ID - description: Target ID of the notification, which should be mark read. - required: true - example: 1234 - selector: - text: diff --git a/tests/components/persistent_notification/test_init.py b/tests/components/persistent_notification/test_init.py index 1e083814e0e6..4f0851dc477e 100644 --- a/tests/components/persistent_notification/test_init.py +++ b/tests/components/persistent_notification/test_init.py @@ -25,7 +25,6 @@ async def test_create(hass: HomeAssistant) -> None: assert len(notifications) == 1 notification = notifications[list(notifications)[0]] - assert notification["status"] == pn.STATUS_UNREAD assert notification["message"] == "Hello World 2" assert notification["title"] == "2 beers" assert notification["created_at"] is not None @@ -66,39 +65,6 @@ async def test_dismiss_notification(hass: HomeAssistant) -> None: assert len(notifications) == 0 -async def test_mark_read(hass: HomeAssistant) -> None: - """Ensure notification is marked as Read.""" - notifications = pn._async_get_or_create_notifications(hass) - assert len(notifications) == 0 - - await hass.services.async_call( - pn.DOMAIN, - "create", - {"notification_id": "Beer 2", "message": "test"}, - blocking=True, - ) - - assert len(notifications) == 1 - notification = notifications[list(notifications)[0]] - assert notification["status"] == pn.STATUS_UNREAD - - await hass.services.async_call( - pn.DOMAIN, "mark_read", {"notification_id": "Beer 2"}, blocking=True - ) - - assert len(notifications) == 1 - notification = notifications[list(notifications)[0]] - assert notification["status"] == pn.STATUS_READ - - await hass.services.async_call( - pn.DOMAIN, - "dismiss", - {"notification_id": "Beer 2"}, - blocking=True, - ) - assert len(notifications) == 0 - - async def test_ws_get_notifications( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -128,19 +94,8 @@ async def test_ws_get_notifications( assert notification["notification_id"] == "Beer 2" assert notification["message"] == "test" assert notification["title"] is None - assert notification["status"] == pn.STATUS_UNREAD assert notification["created_at"] is not None - # Mark Read - await hass.services.async_call( - pn.DOMAIN, "mark_read", {"notification_id": "Beer 2"} - ) - await client.send_json({"id": 7, "type": "persistent_notification/get"}) - msg = await client.receive_json() - notifications = msg["result"] - assert len(notifications) == 1 - assert notifications[0]["status"] == pn.STATUS_READ - # Dismiss pn.async_dismiss(hass, "Beer 2") await client.send_json({"id": 8, "type": "persistent_notification/get"}) @@ -186,24 +141,8 @@ async def test_ws_get_subscribe( assert notification["notification_id"] == "Beer 2" assert notification["message"] == "test" assert notification["title"] is None - assert notification["status"] == pn.STATUS_UNREAD assert notification["created_at"] is not None - # Mark Read - await hass.services.async_call( - pn.DOMAIN, "mark_read", {"notification_id": "Beer 2"} - ) - msg = await client.receive_json() - assert msg["id"] == 5 - assert msg["type"] == "event" - assert msg["event"] - event = msg["event"] - assert event["type"] == "updated" - notifications = event["notifications"] - assert len(notifications) == 1 - notification = notifications[list(notifications)[0]] - assert notification["status"] == pn.STATUS_READ - # Dismiss pn.async_dismiss(hass, "Beer 2") msg = await client.receive_json() From 26e08abb9a1fb3799503a3f61f44c8f4174814d9 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 6 Jun 2023 11:44:29 -0400 Subject: [PATCH 810/822] Revert "Increase Zigbee command retries (#93877)" (#94123) --- .../zha/core/cluster_handlers/__init__.py | 4 - .../zha/test_alarm_control_panel.py | 5 - tests/components/zha/test_device_action.py | 4 +- tests/components/zha/test_discover.py | 2 +- tests/components/zha/test_light.py | 98 ++++++++----------- tests/components/zha/test_switch.py | 4 +- 6 files changed, 46 insertions(+), 71 deletions(-) diff --git a/homeassistant/components/zha/core/cluster_handlers/__init__.py b/homeassistant/components/zha/core/cluster_handlers/__init__.py index ec29e4e53eb4..7863b0434555 100644 --- a/homeassistant/components/zha/core/cluster_handlers/__init__.py +++ b/homeassistant/components/zha/core/cluster_handlers/__init__.py @@ -45,8 +45,6 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) -DEFAULT_REQUEST_RETRIES = 3 - class AttrReportConfig(TypedDict, total=True): """Configuration to report for the attributes.""" @@ -80,8 +78,6 @@ def decorate_command(cluster_handler, command): @wraps(command) async def wrapper(*args, **kwds): - kwds.setdefault("tries", DEFAULT_REQUEST_RETRIES) - try: result = await command(*args, **kwds) cluster_handler.debug( diff --git a/tests/components/zha/test_alarm_control_panel.py b/tests/components/zha/test_alarm_control_panel.py index 34ce746e1287..319301cf7dc3 100644 --- a/tests/components/zha/test_alarm_control_panel.py +++ b/tests/components/zha/test_alarm_control_panel.py @@ -96,7 +96,6 @@ async def test_alarm_control_panel( 0, security.IasAce.AudibleNotification.Default_Sound, security.IasAce.AlarmStatus.No_Alarm, - tries=3, ) # disarm from HA @@ -135,7 +134,6 @@ async def test_alarm_control_panel( 0, security.IasAce.AudibleNotification.Default_Sound, security.IasAce.AlarmStatus.Emergency, - tries=3, ) # reset the panel @@ -159,7 +157,6 @@ async def test_alarm_control_panel( 0, security.IasAce.AudibleNotification.Default_Sound, security.IasAce.AlarmStatus.No_Alarm, - tries=3, ) # arm_night from HA @@ -180,7 +177,6 @@ async def test_alarm_control_panel( 0, security.IasAce.AudibleNotification.Default_Sound, security.IasAce.AlarmStatus.No_Alarm, - tries=3, ) # reset the panel @@ -278,6 +274,5 @@ async def reset_alarm_panel(hass, cluster, entity_id): 0, security.IasAce.AudibleNotification.Default_Sound, security.IasAce.AlarmStatus.No_Alarm, - tries=3, ) cluster.client_command.reset_mock() diff --git a/tests/components/zha/test_device_action.py b/tests/components/zha/test_device_action.py index 9d9a4bc2a54e..f1ab44f69eb2 100644 --- a/tests/components/zha/test_device_action.py +++ b/tests/components/zha/test_device_action.py @@ -328,7 +328,7 @@ async def test_action(hass: HomeAssistant, device_ias, device_inovelli) -> None: 5, expect_reply=False, manufacturer=4151, - tries=3, + tries=1, tsn=None, ) in cluster.request.call_args_list @@ -345,7 +345,7 @@ async def test_action(hass: HomeAssistant, device_ias, device_inovelli) -> None: 5, expect_reply=False, manufacturer=4151, - tries=3, + tries=1, tsn=None, ) in cluster.request.call_args_list diff --git a/tests/components/zha/test_discover.py b/tests/components/zha/test_discover.py index a87d624ec00f..236a3c4ad86a 100644 --- a/tests/components/zha/test_discover.py +++ b/tests/components/zha/test_discover.py @@ -131,7 +131,7 @@ async def test_devices( ), expect_reply=True, manufacturer=None, - tries=3, + tries=1, tsn=None, ) ] diff --git a/tests/components/zha/test_light.py b/tests/components/zha/test_light.py index 5ea71573a27c..c4751f7e7f6e 100644 --- a/tests/components/zha/test_light.py +++ b/tests/components/zha/test_light.py @@ -553,7 +553,7 @@ async def test_transitions( transition_time=0, expect_reply=True, manufacturer=None, - tries=3, + tries=1, tsn=None, ) @@ -589,7 +589,7 @@ async def test_transitions( transition_time=35, expect_reply=True, manufacturer=None, - tries=3, + tries=1, tsn=None, ) assert dev1_cluster_color.request.call_args == call( @@ -600,7 +600,7 @@ async def test_transitions( transition_time=35, expect_reply=True, manufacturer=None, - tries=3, + tries=1, tsn=None, ) @@ -637,7 +637,7 @@ async def test_transitions( transition_time=0, expect_reply=True, manufacturer=None, - tries=3, + tries=1, tsn=None, ) @@ -674,7 +674,7 @@ async def test_transitions( transition_time=0, expect_reply=True, manufacturer=None, - tries=3, + tries=1, tsn=None, ) assert dev1_cluster_color.request.call_args == call( @@ -685,7 +685,7 @@ async def test_transitions( transition_time=0, # no transition when new_color_provided_while_off expect_reply=True, manufacturer=None, - tries=3, + tries=1, tsn=None, ) assert dev1_cluster_level.request.call_args_list[1] == call( @@ -696,7 +696,7 @@ async def test_transitions( transition_time=10, expect_reply=True, manufacturer=None, - tries=3, + tries=1, tsn=None, ) @@ -758,7 +758,7 @@ async def test_transitions( transition_time=0, expect_reply=True, manufacturer=None, - tries=3, + tries=1, tsn=None, ) assert dev1_cluster_color.request.call_args == call( @@ -769,7 +769,7 @@ async def test_transitions( transition_time=0, # no transition when new_color_provided_while_off expect_reply=True, manufacturer=None, - tries=3, + tries=1, tsn=None, ) assert dev1_cluster_level.request.call_args_list[1] == call( @@ -780,7 +780,7 @@ async def test_transitions( transition_time=0, expect_reply=True, manufacturer=None, - tries=3, + tries=1, tsn=None, ) @@ -838,7 +838,7 @@ async def test_transitions( dev1_cluster_on_off.commands_by_name["on"].schema, expect_reply=True, manufacturer=None, - tries=3, + tries=1, tsn=None, ) @@ -850,7 +850,7 @@ async def test_transitions( transition_time=0, # no transition when new_color_provided_while_off expect_reply=True, manufacturer=None, - tries=3, + tries=1, tsn=None, ) @@ -910,7 +910,7 @@ async def test_transitions( transition_time=1, # transition time - sengled light uses default minimum expect_reply=True, manufacturer=None, - tries=3, + tries=1, tsn=None, ) @@ -968,7 +968,7 @@ async def test_transitions( transition_time=1, expect_reply=True, manufacturer=None, - tries=3, + tries=1, tsn=None, ) assert dev2_cluster_color.request.call_args == call( @@ -979,7 +979,7 @@ async def test_transitions( transition_time=1, # sengled transition == 1 when new_color_provided_while_off expect_reply=True, manufacturer=None, - tries=3, + tries=1, tsn=None, ) assert dev2_cluster_level.request.call_args_list[1] == call( @@ -990,7 +990,7 @@ async def test_transitions( transition_time=10, expect_reply=True, manufacturer=None, - tries=3, + tries=1, tsn=None, ) @@ -1121,7 +1121,7 @@ async def test_transitions( transition_time=20, # transition time expect_reply=True, manufacturer=None, - tries=3, + tries=1, tsn=None, ) @@ -1151,7 +1151,7 @@ async def test_transitions( transition_time=1, # transition time - sengled light uses default minimum expect_reply=True, manufacturer=None, - tries=3, + tries=1, tsn=None, ) @@ -1184,7 +1184,7 @@ async def test_transitions( eWeLink_cluster_on_off.commands_by_name["on"].schema, expect_reply=True, manufacturer=None, - tries=3, + tries=1, tsn=None, ) assert dev1_cluster_color.request.call_args == call( @@ -1195,7 +1195,7 @@ async def test_transitions( transition_time=0, expect_reply=True, manufacturer=None, - tries=3, + tries=1, tsn=None, ) @@ -1261,7 +1261,7 @@ async def test_on_with_off_color(hass: HomeAssistant, device_light_1) -> None: dev1_cluster_on_off.commands_by_name["on"].schema, expect_reply=True, manufacturer=None, - tries=3, + tries=1, tsn=None, ) assert dev1_cluster_color.request.call_args == call( @@ -1272,7 +1272,7 @@ async def test_on_with_off_color(hass: HomeAssistant, device_light_1) -> None: transition_time=0, expect_reply=True, manufacturer=None, - tries=3, + tries=1, tsn=None, ) @@ -1319,7 +1319,7 @@ async def test_on_with_off_color(hass: HomeAssistant, device_light_1) -> None: transition_time=0, expect_reply=True, manufacturer=None, - tries=3, + tries=1, tsn=None, ) assert dev1_cluster_color.request.call_args == call( @@ -1330,7 +1330,7 @@ async def test_on_with_off_color(hass: HomeAssistant, device_light_1) -> None: transition_time=0, expect_reply=True, manufacturer=None, - tries=3, + tries=1, tsn=None, ) assert dev1_cluster_level.request.call_args_list[1] == call( @@ -1341,7 +1341,7 @@ async def test_on_with_off_color(hass: HomeAssistant, device_light_1) -> None: transition_time=0, expect_reply=True, manufacturer=None, - tries=3, + tries=1, tsn=None, ) @@ -1373,9 +1373,7 @@ async def async_test_on_from_light(hass, cluster, entity_id): assert hass.states.get(entity_id).state == STATE_ON -async def async_test_on_off_from_hass( - hass, cluster, entity_id, expected_tries: int = 3 -): +async def async_test_on_off_from_hass(hass, cluster, entity_id): """Test on off functionality from hass.""" # turn on via UI cluster.request.reset_mock() @@ -1390,16 +1388,14 @@ async def async_test_on_off_from_hass( cluster.commands_by_name["on"].schema, expect_reply=True, manufacturer=None, - tries=expected_tries, + tries=1, tsn=None, ) - await async_test_off_from_hass( - hass, cluster, entity_id, expected_tries=expected_tries - ) + await async_test_off_from_hass(hass, cluster, entity_id) -async def async_test_off_from_hass(hass, cluster, entity_id, expected_tries: int = 3): +async def async_test_off_from_hass(hass, cluster, entity_id): """Test turning off the light from Home Assistant.""" # turn off via UI @@ -1415,18 +1411,13 @@ async def async_test_off_from_hass(hass, cluster, entity_id, expected_tries: int cluster.commands_by_name["off"].schema, expect_reply=True, manufacturer=None, - tries=expected_tries, + tries=1, tsn=None, ) async def async_test_level_on_off_from_hass( - hass, - on_off_cluster, - level_cluster, - entity_id, - expected_default_transition: int = 0, - expected_tries: int = 3, + hass, on_off_cluster, level_cluster, entity_id, expected_default_transition: int = 0 ): """Test on off functionality from hass.""" @@ -1448,7 +1439,7 @@ async def async_test_level_on_off_from_hass( on_off_cluster.commands_by_name["on"].schema, expect_reply=True, manufacturer=None, - tries=expected_tries, + tries=1, tsn=None, ) on_off_cluster.request.reset_mock() @@ -1472,7 +1463,7 @@ async def async_test_level_on_off_from_hass( on_off_cluster.commands_by_name["on"].schema, expect_reply=True, manufacturer=None, - tries=expected_tries, + tries=1, tsn=None, ) assert level_cluster.request.call_args == call( @@ -1483,7 +1474,7 @@ async def async_test_level_on_off_from_hass( transition_time=100, expect_reply=True, manufacturer=None, - tries=expected_tries, + tries=1, tsn=None, ) on_off_cluster.request.reset_mock() @@ -1508,15 +1499,13 @@ async def async_test_level_on_off_from_hass( transition_time=int(expected_default_transition), expect_reply=True, manufacturer=None, - tries=expected_tries, + tries=1, tsn=None, ) on_off_cluster.request.reset_mock() level_cluster.request.reset_mock() - await async_test_off_from_hass( - hass, on_off_cluster, entity_id, expected_tries=expected_tries - ) + await async_test_off_from_hass(hass, on_off_cluster, entity_id) async def async_test_dimmer_from_light(hass, cluster, entity_id, level, expected_state): @@ -1533,9 +1522,7 @@ async def async_test_dimmer_from_light(hass, cluster, entity_id, level, expected assert hass.states.get(entity_id).attributes.get("brightness") == level -async def async_test_flash_from_hass( - hass, cluster, entity_id, flash, expected_tries: int = 3 -): +async def async_test_flash_from_hass(hass, cluster, entity_id, flash): """Test flash functionality from hass.""" # turn on via UI cluster.request.reset_mock() @@ -1555,7 +1542,7 @@ async def async_test_flash_from_hass( effect_variant=general.Identify.EffectVariant.Default, expect_reply=True, manufacturer=None, - tries=expected_tries, + tries=1, tsn=None, ) @@ -1655,15 +1642,13 @@ async def test_zha_group_light_entity( assert "color_mode" not in group_state.attributes # test turning the lights on and off from the HA - await async_test_on_off_from_hass( - hass, group_cluster_on_off, group_entity_id, expected_tries=1 - ) + await async_test_on_off_from_hass(hass, group_cluster_on_off, group_entity_id) await async_shift_time(hass) # test short flashing the lights from the HA await async_test_flash_from_hass( - hass, group_cluster_identify, group_entity_id, FLASH_SHORT, expected_tries=1 + hass, group_cluster_identify, group_entity_id, FLASH_SHORT ) await async_shift_time(hass) @@ -1678,7 +1663,6 @@ async def test_zha_group_light_entity( group_cluster_level, group_entity_id, expected_default_transition=1, # a Sengled light is in that group and needs a minimum 0.1s transition - expected_tries=1, ) await async_shift_time(hass) @@ -1699,7 +1683,7 @@ async def test_zha_group_light_entity( # test long flashing the lights from the HA await async_test_flash_from_hass( - hass, group_cluster_identify, group_entity_id, FLASH_LONG, expected_tries=1 + hass, group_cluster_identify, group_entity_id, FLASH_LONG ) await async_shift_time(hass) diff --git a/tests/components/zha/test_switch.py b/tests/components/zha/test_switch.py index 8fb7825a9537..9f98acb93597 100644 --- a/tests/components/zha/test_switch.py +++ b/tests/components/zha/test_switch.py @@ -176,7 +176,7 @@ async def test_switch( cluster.commands_by_name["on"].schema, expect_reply=True, manufacturer=None, - tries=3, + tries=1, tsn=None, ) @@ -196,7 +196,7 @@ async def test_switch( cluster.commands_by_name["off"].schema, expect_reply=True, manufacturer=None, - tries=3, + tries=1, tsn=None, ) From 93d52d88351bea85b046fecec22e5469111c2da2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Fran=C3=A7ois=20Roy?= Date: Tue, 6 Jun 2023 13:42:49 -0400 Subject: [PATCH 811/822] Bump aiobafi6 to 0.8.2 (#94125) --- homeassistant/components/baf/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/baf/manifest.json b/homeassistant/components/baf/manifest.json index b5b5b76967e6..37fd5cee7c66 100644 --- a/homeassistant/components/baf/manifest.json +++ b/homeassistant/components/baf/manifest.json @@ -5,7 +5,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/baf", "iot_class": "local_push", - "requirements": ["aiobafi6==0.8.0"], + "requirements": ["aiobafi6==0.8.2"], "zeroconf": [ { "type": "_api._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 10fa122d30c5..866a5bb8c994 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -134,7 +134,7 @@ aioasuswrt==1.4.0 aioazuredevops==1.3.5 # homeassistant.components.baf -aiobafi6==0.8.0 +aiobafi6==0.8.2 # homeassistant.components.aws aiobotocore==2.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0eac0f7fbce2..660e296d30dd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -124,7 +124,7 @@ aioasuswrt==1.4.0 aioazuredevops==1.3.5 # homeassistant.components.baf -aiobafi6==0.8.0 +aiobafi6==0.8.2 # homeassistant.components.aws aiobotocore==2.1.0 From e6638ca3563c80409a6d6ef356854b68467c20bb Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Wed, 7 Jun 2023 03:04:53 +0300 Subject: [PATCH 812/822] Remove goalfeed integration (#94129) --- .coveragerc | 1 - homeassistant/components/goalfeed/__init__.py | 65 ------------------- .../components/goalfeed/manifest.json | 9 --- homeassistant/generated/integrations.json | 6 -- requirements_all.txt | 3 - 5 files changed, 84 deletions(-) delete mode 100644 homeassistant/components/goalfeed/__init__.py delete mode 100644 homeassistant/components/goalfeed/manifest.json diff --git a/.coveragerc b/.coveragerc index c244617007fc..8494ef357bff 100644 --- a/.coveragerc +++ b/.coveragerc @@ -420,7 +420,6 @@ omit = homeassistant/components/gitlab_ci/sensor.py homeassistant/components/gitter/sensor.py homeassistant/components/glances/sensor.py - homeassistant/components/goalfeed/* homeassistant/components/goodwe/__init__.py homeassistant/components/goodwe/button.py homeassistant/components/goodwe/coordinator.py diff --git a/homeassistant/components/goalfeed/__init__.py b/homeassistant/components/goalfeed/__init__.py deleted file mode 100644 index f452b858e799..000000000000 --- a/homeassistant/components/goalfeed/__init__.py +++ /dev/null @@ -1,65 +0,0 @@ -"""Component for the Goalfeed service.""" -import json - -import pysher -import requests -import voluptuous as vol - -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.typing import ConfigType - -# Version downgraded due to regression in library -# For details: https://github.com/nlsdfnbch/Pysher/issues/38 -DOMAIN = "goalfeed" - -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - } - ) - }, - extra=vol.ALLOW_EXTRA, -) - -GOALFEED_HOST = "feed.goalfeed.ca" -GOALFEED_AUTH_ENDPOINT = "https://goalfeed.ca/feed/auth" -GOALFEED_APP_ID = "bfd4ed98c1ff22c04074" - - -def setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Goalfeed component.""" - conf = config[DOMAIN] - username = conf.get(CONF_USERNAME) - password = conf.get(CONF_PASSWORD) - - def goal_handler(data): - """Handle goal events.""" - goal = json.loads(json.loads(data)) - - hass.bus.fire("goal", event_data=goal) - - def connect_handler(data): - """Handle connection.""" - post_data = { - "username": username, - "password": password, - "connection_info": data, - } - resp = requests.post(GOALFEED_AUTH_ENDPOINT, post_data, timeout=30).json() - - channel = pusher.subscribe("private-goals", resp["auth"]) - channel.bind("goal", goal_handler) - - pusher = pysher.Pusher( - GOALFEED_APP_ID, secure=False, port=8080, custom_host=GOALFEED_HOST - ) - - pusher.connection.bind("pusher:connection_established", connect_handler) - pusher.connect() - - return True diff --git a/homeassistant/components/goalfeed/manifest.json b/homeassistant/components/goalfeed/manifest.json deleted file mode 100644 index 077596b01853..000000000000 --- a/homeassistant/components/goalfeed/manifest.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "domain": "goalfeed", - "name": "Goalfeed", - "codeowners": [], - "documentation": "https://www.home-assistant.io/integrations/goalfeed", - "iot_class": "cloud_push", - "loggers": ["pysher"], - "requirements": ["pysher==1.0.7"] -} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 6418e93aa033..6f13633e3060 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1995,12 +1995,6 @@ } } }, - "goalfeed": { - "name": "Goalfeed", - "integration_type": "hub", - "config_flow": false, - "iot_class": "cloud_push" - }, "goalzero": { "name": "Goal Zero Yeti", "integration_type": "device", diff --git a/requirements_all.txt b/requirements_all.txt index 866a5bb8c994..b90de9ec7d8c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1966,9 +1966,6 @@ pyserial==3.5 # homeassistant.components.sesame pysesame2==1.0.1 -# homeassistant.components.goalfeed -pysher==1.0.7 - # homeassistant.components.sia pysiaalarm==3.1.1 From 23f28988364167abfdfecf82f8b2e719b2ba2a71 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Tue, 6 Jun 2023 23:03:59 +0200 Subject: [PATCH 813/822] Correct zha device classes for voc and pm25 (#94130) Correct zha device classes --- homeassistant/components/zha/sensor.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index da1f1f6c04c3..918458a32ad2 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -722,7 +722,9 @@ class PPBVOCLevel(Sensor): """VOC Level sensor.""" SENSOR_ATTR = "measured_value" - _attr_device_class: SensorDeviceClass = SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS + _attr_device_class: SensorDeviceClass = ( + SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS + ) _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT _attr_name: str = "VOC level" _decimals = 0 @@ -736,6 +738,7 @@ class PM25(Sensor): """Particulate Matter 2.5 microns or less sensor.""" SENSOR_ATTR = "measured_value" + _attr_device_class: SensorDeviceClass = SensorDeviceClass.PM25 _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT _attr_name: str = "Particulate matter" _decimals = 0 From b077bf9b86c941ddadacdec4ab8a2aebde7181b8 Mon Sep 17 00:00:00 2001 From: Christopher Bailey Date: Tue, 6 Jun 2023 20:07:21 -0400 Subject: [PATCH 814/822] Fix multiple smart detects firing at once for UniFi Protect (#94133) * Fix multiple smart detects firing at once * Tweak * Clean up logging. Linting * Linting --- .../components/unifiprotect/binary_sensor.py | 20 ++++------- homeassistant/components/unifiprotect/data.py | 24 +++++++++++++ .../components/unifiprotect/manifest.json | 2 +- .../components/unifiprotect/models.py | 34 +++++++++---------- .../components/unifiprotect/sensor.py | 7 ++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/unifiprotect/test_sensor.py | 4 ++- 8 files changed, 55 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/unifiprotect/binary_sensor.py b/homeassistant/components/unifiprotect/binary_sensor.py index 7aa7c6d5cf14..fe4399c4c6d3 100644 --- a/homeassistant/components/unifiprotect/binary_sensor.py +++ b/homeassistant/components/unifiprotect/binary_sensor.py @@ -14,8 +14,6 @@ from pyunifiprotect.data import ( ProtectAdoptableDeviceModel, ProtectModelWithId, Sensor, - SmartDetectAudioType, - SmartDetectObjectType, ) from pyunifiprotect.data.nvr import UOSDisk @@ -364,8 +362,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( ufp_value="is_smart_detected", ufp_required_field="can_detect_person", ufp_enabled="is_person_detection_on", - ufp_event_obj="last_smart_detect_event", - ufp_smart_type=SmartDetectObjectType.PERSON, + ufp_event_obj="last_person_detect_event", ), ProtectBinaryEventEntityDescription( key="smart_obj_vehicle", @@ -374,8 +371,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( ufp_value="is_smart_detected", ufp_required_field="can_detect_vehicle", ufp_enabled="is_vehicle_detection_on", - ufp_event_obj="last_smart_detect_event", - ufp_smart_type=SmartDetectObjectType.VEHICLE, + ufp_event_obj="last_vehicle_detect_event", ), ProtectBinaryEventEntityDescription( key="smart_obj_face", @@ -384,8 +380,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( ufp_value="is_smart_detected", ufp_required_field="can_detect_face", ufp_enabled="is_face_detection_on", - ufp_event_obj="last_smart_detect_event", - ufp_smart_type=SmartDetectObjectType.FACE, + ufp_event_obj="last_face_detect_event", ), ProtectBinaryEventEntityDescription( key="smart_obj_package", @@ -394,8 +389,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( ufp_value="is_smart_detected", ufp_required_field="can_detect_package", ufp_enabled="is_package_detection_on", - ufp_event_obj="last_smart_detect_event", - ufp_smart_type=SmartDetectObjectType.PACKAGE, + ufp_event_obj="last_package_detect_event", ), ProtectBinaryEventEntityDescription( key="smart_audio_any", @@ -412,8 +406,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( ufp_value="is_smart_detected", ufp_required_field="can_detect_smoke", ufp_enabled="is_smoke_detection_on", - ufp_event_obj="last_smart_audio_detect_event", - ufp_smart_type=SmartDetectAudioType.SMOKE, + ufp_event_obj="last_smoke_detect_event", ), ProtectBinaryEventEntityDescription( key="smart_audio_cmonx", @@ -422,8 +415,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( ufp_value="is_smart_detected", ufp_required_field="can_detect_smoke", ufp_enabled="is_smoke_detection_on", - ufp_event_obj="last_smart_audio_detect_event", - ufp_smart_type=SmartDetectAudioType.CMONX, + ufp_event_obj="last_cmonx_detect_event", ), ) diff --git a/homeassistant/components/unifiprotect/data.py b/homeassistant/components/unifiprotect/data.py index 68d48003ba63..88c500f18fd0 100644 --- a/homeassistant/components/unifiprotect/data.py +++ b/homeassistant/components/unifiprotect/data.py @@ -40,6 +40,11 @@ from .utils import async_dispatch_id as _ufpd, async_get_devices_by_type _LOGGER = logging.getLogger(__name__) ProtectDeviceType = ProtectAdoptableDeviceModel | NVR +SMART_EVENTS = { + EventType.SMART_DETECT, + EventType.SMART_AUDIO_DETECT, + EventType.SMART_DETECT_LINE, +} @callback @@ -223,6 +228,25 @@ class ProtectData: # trigger updates for camera that the event references elif isinstance(obj, Event): + if obj.type in SMART_EVENTS: + if obj.camera is not None: + if obj.end is None: + _LOGGER.debug( + "%s (%s): New smart detection started for %s (%s)", + obj.camera.name, + obj.camera.mac, + obj.smart_detect_types, + obj.id, + ) + else: + _LOGGER.debug( + "%s (%s): Smart detection ended for %s (%s)", + obj.camera.name, + obj.camera.mac, + obj.smart_detect_types, + obj.id, + ) + if obj.type == EventType.DEVICE_ADOPTED: if obj.metadata is not None and obj.metadata.device_id is not None: device = self.api.bootstrap.get_device_from_id( diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index a2bb76c92b75..e16180b03bc1 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.9.1", "unifi-discovery==1.1.7"], + "requirements": ["pyunifiprotect==4.10.0", "unifi-discovery==1.1.7"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/components/unifiprotect/models.py b/homeassistant/components/unifiprotect/models.py index 40280c028679..8c6882316281 100644 --- a/homeassistant/components/unifiprotect/models.py +++ b/homeassistant/components/unifiprotect/models.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable, Coroutine from dataclasses import dataclass +from datetime import timedelta from enum import Enum import logging from typing import Any, Generic, TypeVar, cast @@ -10,6 +11,7 @@ from typing import Any, Generic, TypeVar, cast from pyunifiprotect.data import NVR, Event, ProtectAdoptableDeviceModel from homeassistant.helpers.entity import EntityDescription +from homeassistant.util import dt as dt_util from .utils import get_nested_attr @@ -67,7 +69,6 @@ class ProtectEventMixin(ProtectRequiredKeysMixin[T]): """Mixin for events.""" ufp_event_obj: str | None = None - ufp_smart_type: str | None = None def get_event_obj(self, obj: T) -> Event | None: """Return value from UniFi Protect device.""" @@ -79,23 +80,22 @@ class ProtectEventMixin(ProtectRequiredKeysMixin[T]): def get_is_on(self, obj: T) -> bool: """Return value if event is active.""" - value = bool(self.get_ufp_value(obj)) - if value: - event = self.get_event_obj(obj) - value = event is not None - if not value: - _LOGGER.debug("%s (%s): missing event", self.name, obj.mac) + event = self.get_event_obj(obj) + if event is None: + return False - if event is not None and self.ufp_smart_type is not None: - value = self.ufp_smart_type in event.smart_detect_types - if not value: - _LOGGER.debug( - "%s (%s): %s not in %s", - self.name, - obj.mac, - self.ufp_smart_type, - event.smart_detect_types, - ) + now = dt_util.utcnow() + value = now > event.start + if value and event.end is not None and now > event.end: + value = False + # only log if the recent ended recently + if event.end + timedelta(seconds=10) < now: + _LOGGER.debug( + "%s (%s): end ended at %s", + self.name, + obj.mac, + event.end.isoformat(), + ) if value: _LOGGER.debug("%s (%s): value is on", self.name, obj.mac) diff --git a/homeassistant/components/unifiprotect/sensor.py b/homeassistant/components/unifiprotect/sensor.py index 783955b34012..dec6f10a57f1 100644 --- a/homeassistant/components/unifiprotect/sensor.py +++ b/homeassistant/components/unifiprotect/sensor.py @@ -15,7 +15,6 @@ from pyunifiprotect.data import ( ProtectDeviceModel, ProtectModelWithId, Sensor, - SmartDetectObjectType, ) from homeassistant.components.sensor import ( @@ -528,10 +527,9 @@ EVENT_SENSORS: tuple[ProtectSensorEventEntityDescription, ...] = ( name="License Plate Detected", icon="mdi:car", translation_key="license_plate", - ufp_smart_type=SmartDetectObjectType.LICENSE_PLATE, ufp_value="is_smart_detected", ufp_required_field="can_detect_license_plate", - ufp_event_obj="last_smart_detect_event", + ufp_event_obj="last_license_plate_detect_event", ), ) @@ -767,8 +765,7 @@ class ProtectEventSensor(EventEntityMixin, SensorEntity): EventEntityMixin._async_update_device_from_protect(self, device) is_on = self.entity_description.get_is_on(device) is_license_plate = ( - self.entity_description.ufp_smart_type - == SmartDetectObjectType.LICENSE_PLATE + self.entity_description.ufp_event_obj == "last_license_plate_detect_event" ) if ( not is_on diff --git a/requirements_all.txt b/requirements_all.txt index b90de9ec7d8c..854a82af9640 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2168,7 +2168,7 @@ pytrafikverket==0.3.3 pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.9.1 +pyunifiprotect==4.10.0 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 660e296d30dd..4f69bf74baf6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1579,7 +1579,7 @@ pytrafikverket==0.3.3 pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.9.1 +pyunifiprotect==4.10.0 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 diff --git a/tests/components/unifiprotect/test_sensor.py b/tests/components/unifiprotect/test_sensor.py index db7cdc801bf9..89a153caed28 100644 --- a/tests/components/unifiprotect/test_sensor.py +++ b/tests/components/unifiprotect/test_sensor.py @@ -537,7 +537,9 @@ async def test_camera_update_licenseplate( new_camera = camera.copy() new_camera.is_smart_detected = True - new_camera.last_smart_detect_event_id = event.id + new_camera.last_smart_detect_event_ids[ + SmartDetectObjectType.LICENSE_PLATE + ] = event.id mock_msg = Mock() mock_msg.changed_data = {} From 7740539df00f14e672d75f5b6f6f4e8cea32712f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 6 Jun 2023 20:55:25 -0400 Subject: [PATCH 815/822] Bump waqiasync to 1.1.0 (#94136) --- homeassistant/components/waqi/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/waqi/manifest.json b/homeassistant/components/waqi/manifest.json index d1c75217830b..e5630d5fd297 100644 --- a/homeassistant/components/waqi/manifest.json +++ b/homeassistant/components/waqi/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/waqi", "iot_class": "cloud_polling", "loggers": ["waqiasync"], - "requirements": ["waqiasync==1.0.0"] + "requirements": ["waqiasync==1.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 854a82af9640..7a3e7ac699a5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2632,7 +2632,7 @@ wakeonlan==2.1.0 wallbox==0.4.12 # homeassistant.components.waqi -waqiasync==1.0.0 +waqiasync==1.1.0 # homeassistant.components.folder_watcher watchdog==2.3.1 From f4e3ef6b515787c78ffdd2117233f8f149cfd883 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 6 Jun 2023 22:00:28 -0400 Subject: [PATCH 816/822] Bumped version to 2023.6.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 8b872c061c98..dadc9bfc9b1d 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -8,7 +8,7 @@ from .backports.enum import StrEnum APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 MINOR_VERSION: Final = 6 -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, 10, 0) diff --git a/pyproject.toml b/pyproject.toml index 29411420d42f..94ce62f7bdd3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.6.0b5" +version = "2023.6.0b6" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 02d55a8e4969f6399259f6a9e16712c2e4db5ca8 Mon Sep 17 00:00:00 2001 From: Christopher Bailey Date: Wed, 7 Jun 2023 05:15:03 -0400 Subject: [PATCH 817/822] Bump unifiprotect to 4.10.1 (#94141) --- 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 e16180b03bc1..78e2ee3012c1 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.0", "unifi-discovery==1.1.7"], + "requirements": ["pyunifiprotect==4.10.1", "unifi-discovery==1.1.7"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 7a3e7ac699a5..b2116bacaca4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2168,7 +2168,7 @@ pytrafikverket==0.3.3 pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.10.0 +pyunifiprotect==4.10.1 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4f69bf74baf6..fb54f734c6a6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1579,7 +1579,7 @@ pytrafikverket==0.3.3 pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.10.0 +pyunifiprotect==4.10.1 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 From 5cc61acfb2045515face106337893fb95195a2c5 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 7 Jun 2023 11:14:51 +0200 Subject: [PATCH 818/822] Fix migration of Google Assistant cloud settings (#94148) --- homeassistant/components/cloud/google_config.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/cloud/google_config.py b/homeassistant/components/cloud/google_config.py index 8592a4ffbe35..1b7375946f7c 100644 --- a/homeassistant/components/cloud/google_config.py +++ b/homeassistant/components/cloud/google_config.py @@ -108,7 +108,12 @@ def _supported_legacy(hass: HomeAssistant, entity_id: str) -> bool: if domain in SUPPORTED_DOMAINS: return True - device_class = get_device_class(hass, entity_id) + try: + device_class = get_device_class(hass, entity_id) + except HomeAssistantError: + # The entity no longer exists + return False + if ( domain == "binary_sensor" and device_class in SUPPORTED_BINARY_SENSOR_DEVICE_CLASSES From 74ccdcda682f586bc9e75f66873b4f92daf561ec Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 7 Jun 2023 12:57:37 +0200 Subject: [PATCH 819/822] Update frontend to 20230607.0 (#94150) --- 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 47bdf60b8e78..af8898f28e2d 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==20230606.0"] + "requirements": ["home-assistant-frontend==20230607.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f02f852aa32b..22d47a0e430c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -25,7 +25,7 @@ ha-av==10.1.0 hass-nabucasa==0.67.1 hassil==1.0.6 home-assistant-bluetooth==1.10.0 -home-assistant-frontend==20230606.0 +home-assistant-frontend==20230607.0 home-assistant-intents==2023.6.5 httpx==0.24.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index b2116bacaca4..8bb4580aa619 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -924,7 +924,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230606.0 +home-assistant-frontend==20230607.0 # homeassistant.components.conversation home-assistant-intents==2023.6.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fb54f734c6a6..bc1cae28d098 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -716,7 +716,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230606.0 +home-assistant-frontend==20230607.0 # homeassistant.components.conversation home-assistant-intents==2023.6.5 From 468be632fd00af1328d62ceeb14957fa2516bb47 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 7 Jun 2023 13:16:23 +0200 Subject: [PATCH 820/822] Add debug logs to cloud migration (#94151) --- homeassistant/components/cloud/alexa_config.py | 10 ++++++++++ homeassistant/components/cloud/google_config.py | 10 ++++++++++ 2 files changed, 20 insertions(+) diff --git a/homeassistant/components/cloud/alexa_config.py b/homeassistant/components/cloud/alexa_config.py index 052fddabb54f..8c1300f6228a 100644 --- a/homeassistant/components/cloud/alexa_config.py +++ b/homeassistant/components/cloud/alexa_config.py @@ -221,6 +221,11 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): async def on_hass_started(hass: HomeAssistant) -> None: if self._prefs.alexa_settings_version != ALEXA_SETTINGS_VERSION: + _LOGGER.info( + "Start migration of Alexa settings from v%s to v%s", + self._prefs.alexa_settings_version, + ALEXA_SETTINGS_VERSION, + ) if self._prefs.alexa_settings_version < 2 or ( # Recover from a bug we had in 2023.5.0 where entities didn't get exposed self._prefs.alexa_settings_version < 3 @@ -233,6 +238,11 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): ): self._migrate_alexa_entity_settings_v1() + _LOGGER.info( + "Finished migration of Alexa settings from v%s to v%s", + self._prefs.alexa_settings_version, + ALEXA_SETTINGS_VERSION, + ) await self._prefs.async_update( alexa_settings_version=ALEXA_SETTINGS_VERSION ) diff --git a/homeassistant/components/cloud/google_config.py b/homeassistant/components/cloud/google_config.py index 1b7375946f7c..0a49c0b6ed63 100644 --- a/homeassistant/components/cloud/google_config.py +++ b/homeassistant/components/cloud/google_config.py @@ -213,6 +213,11 @@ class CloudGoogleConfig(AbstractConfig): async def on_hass_started(hass: HomeAssistant) -> None: if self._prefs.google_settings_version != GOOGLE_SETTINGS_VERSION: + _LOGGER.info( + "Start migration of Google Assistant settings from v%s to v%s", + self._prefs.google_settings_version, + GOOGLE_SETTINGS_VERSION, + ) if self._prefs.google_settings_version < 2 or ( # Recover from a bug we had in 2023.5.0 where entities didn't get exposed self._prefs.google_settings_version < 3 @@ -225,6 +230,11 @@ class CloudGoogleConfig(AbstractConfig): ): self._migrate_google_entity_settings_v1() + _LOGGER.info( + "Finished migration of Google Assistant settings from v%s to v%s", + self._prefs.google_settings_version, + GOOGLE_SETTINGS_VERSION, + ) await self._prefs.async_update( google_settings_version=GOOGLE_SETTINGS_VERSION ) From 3d3fecbd2340d4c0dddeb5c1cb2f77ae251379fc Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 7 Jun 2023 13:46:17 +0200 Subject: [PATCH 821/822] Disable google assistant local control of climate entities (#94153) --- homeassistant/components/google_assistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py index bf511f8eaebb..918cec046fb7 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -186,7 +186,7 @@ STORE_GOOGLE_LOCAL_WEBHOOK_ID = "local_webhook_id" SOURCE_CLOUD = "cloud" SOURCE_LOCAL = "local" -NOT_EXPOSE_LOCAL = {TYPE_ALARM, TYPE_LOCK} +NOT_EXPOSE_LOCAL = {TYPE_ALARM, TYPE_LOCK, TYPE_THERMOSTAT} FAN_SPEEDS = { "5/5": ["High", "Max", "Fast", "5"], From 421fa5b035eee2798d2cbd1155cd7228e131db09 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 7 Jun 2023 13:49:03 +0200 Subject: [PATCH 822/822] Bumped version to 2023.6.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 dadc9bfc9b1d..9308d364ecb2 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -8,7 +8,7 @@ from .backports.enum import StrEnum APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 MINOR_VERSION: Final = 6 -PATCH_VERSION: Final = "0b6" +PATCH_VERSION: Final = "0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 10, 0) diff --git a/pyproject.toml b/pyproject.toml index 94ce62f7bdd3..2ece07d96e63 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.6.0b6" +version = "2023.6.0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst"