From 45d826c9410fa338c8c86c912437abd6ecb5f1ba Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 26 Apr 2023 21:56:02 +0200 Subject: [PATCH 001/197] Bumped version to 2023.5.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 23b4a9a1329..263013f14f2 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 = 5 -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 8a0781e08a9..c126337094c 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.5.0b0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From ba69e29e8f29107c032402355085d82fb80cd7f7 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 002/197] 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 3bec7f883d0..6ce5f656d6e 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 9b35fc7972a..b6c76e00f4c 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 ee6f4f7289f..e1fe41829c2 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 a445e29bcaa27c6f7298e05ab1e8bc9bc7fe3d40 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 003/197] 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 6ce5f656d6e..a68dd562af1 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 b6c76e00f4c..aaa2dc80a78 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 f5362b1fb3d..a70686acbf6 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 e1fe41829c2..fed83f8ab10 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 f7e72ef62b4c3c7e9c69b140139f3947fa3a26dd Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 26 Apr 2023 15:31:35 -0500 Subject: [PATCH 004/197] 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 9b1f111d552..0221d80002c 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 8b9539cd8e9..52429425462 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 d79390f5a88..93782c84047 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 b94fd57291a..8b8d7dfaa25 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 9970af5fe9bee8a345a8c866f6d0c2998b61089c 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 005/197] 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 652f19d24ba..3d44103e225 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 322107a074e..2d4126861b4 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 8e31b45afd8..0621c6521a9 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 b75a65ed5b6..85f85cc0437 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 7a24daaa3ba..720cfaaac9b 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 019f26a17c35b4d722d0d6a8b8e7f6a16502183e 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 006/197] 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 66f4daf200f..34a88528411 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 b3d685cc31c060cffdbef906498c8ac40956e19e Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 27 Apr 2023 01:39:32 +0200 Subject: [PATCH 007/197] 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 52429425462..3a9465869c2 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 c126337094c..79bdf016d77 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 1196e9b6f6f..3f24d2897ce 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 a1d47407859b32b9c67ee01ee95917a4328d4cd9 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 008/197] 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 9342849b2fe..36eb37b7882 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 3852712f70d..ef4dee7c597 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 47c6cb88a451a1599537f974526b580d179b7c23 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 26 Apr 2023 22:13:21 -0400 Subject: [PATCH 009/197] 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 8ee0ad286b9..d85eb1aaed9 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 ccfa4306880..77ec780acb3 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 7171339ac0a..81350c2c653 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 70ecb870984..8eeefbd5d94 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 6eb9d36df73..83931d42c57 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 90cf99747f0..3f780f3003d 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 67a0bef0f62..794d307ee01 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 8db1d13c7151bad8104bad555c23fcc3b8a8d890 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 26 Apr 2023 22:40:17 -0400 Subject: [PATCH 010/197] 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 87fd9be0c42..d347e433f46 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 7400fe32d70..40aa48fbc54 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 5191e948c38..619c59606ed 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 f5a0a6dad92..a2e5ac72b07 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 6dd04c37e39..6fb6bf61d96 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 827d7b85113..c71d0526fe6 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 5c3094520d14e812c9c58a0f0074382b97361f8b 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/197] 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 9b63ef17a9c..999ef269035 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 b0c164da1ed..d85765aec4c 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 a41128dae3262ef4d66c4c2f5eab7fd8b58b79d1 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 27 Apr 2023 10:38:21 +0200 Subject: [PATCH 012/197] 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 c47b05c264c..29b9c62ea1d 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 738b3fa7cd7..8b927f7f3aa 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 9a7f7ef35c5bda85f578aabb8a4d4fabbad62616 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 27 Apr 2023 13:31:24 +0200 Subject: [PATCH 013/197] 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 44a42c78f09..7acfbbb7f6b 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 2cb363b0420..0e1f941ab64 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 1f52b714773871d3c514897017cf6ac8ed050396 Mon Sep 17 00:00:00 2001 From: Thijs W Date: Thu, 27 Apr 2023 13:14:25 +0200 Subject: [PATCH 014/197] 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 4a884063f83..62f2623d05e 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 f40abe16752..193ca7123f4 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 7d5c90a81e38750362fd3965a3ded15f1621bce0 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 27 Apr 2023 17:10:29 +0200 Subject: [PATCH 015/197] 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 7acfbbb7f6b..9c691ebed55 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 8d6c4e65e3c..8bc31f7b862 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 351caff883f..ff4b9be4d3f 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 c35872531f6b8a1bb80ad50c576cd5bb8737a083 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Thu, 27 Apr 2023 19:07:56 +0200 Subject: [PATCH 016/197] 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 e1bf0329de7..409f70f554a 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 3a9465869c2..29beb9af459 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 93782c84047..fdb65da32d4 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 8b8d7dfaa25..bbe4933377c 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 54e52182ab63dc9abb412573be0ae3dee4dd036f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 27 Apr 2023 18:52:43 +0200 Subject: [PATCH 017/197] 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 5ca56de513e..d08a3b45b34 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 61328de9533..af8394e2ad7 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 29beb9af459..6f5adea209c 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 fdb65da32d4..9857700fb72 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 bbe4933377c..2d5f5760098 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 3e8e2c68b95a9c072a6b1eb8dcc4de019302f02e Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 27 Apr 2023 19:51:26 +0200 Subject: [PATCH 018/197] 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 e1b41b54058..d7d5d0278e8 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 685ea38f84e..d4220a39724 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 e03f3c05b3f9afb2b726f3427e3ab8f87be0e756 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 27 Apr 2023 19:59:11 +0200 Subject: [PATCH 019/197] Bumped version to 2023.5.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 263013f14f2..97ae172b7d8 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 = 5 -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 79bdf016d77..25af1c93803 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.5.0b0" +version = "2023.5.0b1" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 7b1b3970b137f5222adfd982bd6e52d349b7fa94 Mon Sep 17 00:00:00 2001 From: Luke Date: Thu, 27 Apr 2023 15:10:34 -0400 Subject: [PATCH 020/197] 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 d0c2147c1ee..fcfad6e8cd3 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 997c0a6acb8..433b46d2899 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 20dee34db05..f433aee25c4 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 ae0adb4ad7d..0377cebd425 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 7e755a0c41f..6bd19787d20 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 9857700fb72..02d4aaa19e9 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 2d5f5760098..282ae2fe058 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 d6afff440f7..cbd5ef379e8 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 d319f0e165d..2f297135d15 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 ef350949fd68867c9b0d7592a060c38ec9ea35ff Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 28 Apr 2023 09:44:23 +0200 Subject: [PATCH 021/197] 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 65180d86d7c..be11b0b034d 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 8017a04efe6056a8d98ccb98f951a175b0414161 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 022/197] 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 6b080db081e..1c29f619719 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 12b1f636866..d4014bbf697 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 ad4bfd7a690..c90c78243d1 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 2e08b0cc6d8..2063a9906ec 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 1bab8a3f2c3..2453f40af44 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 02d4aaa19e9..dccc27eda54 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 @@ -2718,7 +2718,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 282ae2fe058..30479ed7c6c 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 0621c6521a9..271108496b2 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 0ec1ae8aa14..a45d02a3aa2 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 ff2f6029ce39e0d662765ac645fbd01eaeed1013 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 28 Apr 2023 21:03:59 +0200 Subject: [PATCH 023/197] 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 662be41b1c8..95013de125d 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 f8a1b769d87..49f66fdcd68 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 433ff01eb91..613c17b3d39 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 658128c8924810f0ce7b8442bbba33c8f613ba7d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 28 Apr 2023 09:52:20 +0200 Subject: [PATCH 024/197] 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 d000c0346af..9b80d992cdd 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 52388e694f5..6256d1dde9c 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 b7f5c144a8e6c6a850b96f256ed3987851a31c4b Mon Sep 17 00:00:00 2001 From: Luke Date: Fri, 28 Apr 2023 03:49:35 -0400 Subject: [PATCH 025/197] 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 f433aee25c4..7cb686f1851 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 dccc27eda54..34918046c06 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.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 30479ed7c6c..9048191aa02 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 412ea937ff00fdab24a3af6368efb920f5e6410e Mon Sep 17 00:00:00 2001 From: jjlawren Date: Thu, 27 Apr 2023 22:51:51 -0500 Subject: [PATCH 026/197] 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 7ef103e4d04..7e6c210a164 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 fa3f19e7bfff938cc352943072e73074aca5aec1 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 28 Apr 2023 15:59:21 +0200 Subject: [PATCH 027/197] 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 2796e51c27d..f156acfd568 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 b3a66d80307..d347140af2e 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 810e2905034..44bb3111987 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 65e21c2718e..02c9ace7cd4 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 25d621ab940b2898f7684fcce029f4a687a6084c 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 028/197] 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 999ef269035..e6812ed58b1 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 34918046c06..4a967ec5c0f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2176,7 +2176,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 9048191aa02..8d5a8ff0653 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 96d2b53798805f25e96a2ff00cd63d46ff302376 Mon Sep 17 00:00:00 2001 From: Nolan Gilley Date: Fri, 28 Apr 2023 08:30:10 -0400 Subject: [PATCH 029/197] 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 5232fadc428..ccf15144f9e 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 4a967ec5c0f..b9bc0f983b6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1034,7 +1034,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 652bb8ef9515de3c4ddf5d4f9fffd47c7ddf8360 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Fri, 28 Apr 2023 11:29:24 -0400 Subject: [PATCH 030/197] 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 b3ec6b828ee..7863b043455 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 c0a727414b6..d134c033ed7 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 8c789c0fc59..29920eab836 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 98075da06930580f44ad392b13184f1b98dcb4b2 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 28 Apr 2023 18:56:22 +0200 Subject: [PATCH 031/197] 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 55db1da6ff7..cd73ee8efb6 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 62ad74b6a09..498365de4a3 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 1f6dbe96f682f0c9ee676382d9af2d946d9aed44 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Fri, 28 Apr 2023 21:01:28 +0200 Subject: [PATCH 032/197] 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 409f70f554a..e925e68b573 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 6f5adea209c..dcc98f78a77 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 b9bc0f983b6..0d1dace5ee0 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 8d5a8ff0653..f261e7b0249 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 faa8f38fa804be1404ae5c77882b59081eccec82 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 033/197] 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 360926363a5..a166c346f12 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 29bff597078f0cf5b312a3fe1f804c3f01380436 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 28 Apr 2023 21:03:16 +0200 Subject: [PATCH 034/197] 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 a856af83bb8..1512cf7b2b4 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 d3c03e0d805..f2fa109af22 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 1b39abe3bc1a8a119cf138ca955d531e1bc9c0e7 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 28 Apr 2023 21:42:27 +0200 Subject: [PATCH 035/197] Bumped version to 2023.5.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 97ae172b7d8..0de53b959ba 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 = 5 -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 25af1c93803..3a33ca2a3c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.5.0b1" +version = "2023.5.0b2" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From e460bc7ecb06d9a78fe74b32b92778d20e02fac2 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 036/197] 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 e91943034df..8d5d842e915 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 f1768d5a0c7..3c7d2ba27c3 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 00000000000..f26a2027f72 --- /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 e8e8dd5ca40..52d35b477a2 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 0d1dace5ee0..157c79b24cc 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 f261e7b0249..ac8d7471843 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 2cd6622d14e..7ee3f625911 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 00000000000..a99d8bb3e0f --- /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 e6902fbacfd..522e74c61e2 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 00000000000..b6c16af3e03 --- /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 92daf157a70..bbef62b14ed 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 aafbc64e02a61773ffe7a5ad8ad8a9928affe0ac Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Sat, 29 Apr 2023 18:34:21 +0200 Subject: [PATCH 037/197] 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 a40bd731a0f..b7416711e77 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 3f948da2af0d683aec883043a46b50fc3adbb241 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 038/197] 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 d2edb99e026..6d371a82c95 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 89a51581bf7..fa35b240d98 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 2b156046098..d6b78c1cfc0 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 a26a0b2313f..c8a7952ae2b 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 401e61588c57d09eeac961d9ff455cf000347b8f Mon Sep 17 00:00:00 2001 From: Rajeevan Date: Sat, 29 Apr 2023 11:33:43 +0200 Subject: [PATCH 039/197] 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 960ff07b750..d65aa06ea0a 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 35e5d758433..d0efcd0ec9b 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 157c79b24cc..85d7d7f1777 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2378,7 +2378,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 c8cc6bfbb7d78de56a435a57e56d54219fc33da3 Mon Sep 17 00:00:00 2001 From: Tom Harris Date: Sat, 29 Apr 2023 05:16:04 -0400 Subject: [PATCH 040/197] 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 08adce918c1..cc8495384b1 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 85d7d7f1777..09eacef69b6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -983,7 +983,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 ac8d7471843..08f1de3103d 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 7af1521812e5bc0f99ae5d5fc84cfbcdbeb6779a Mon Sep 17 00:00:00 2001 From: jjlawren Date: Fri, 28 Apr 2023 16:10:48 -0500 Subject: [PATCH 041/197] 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 9c6f93fc2a4..4a05053940c 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 09eacef69b6..027dc8c639f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2390,7 +2390,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 08f1de3103d..76dcd3d9fac 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 1d54a0ed3d9b83ba40f0e8dfc2a7df5f29989647 Mon Sep 17 00:00:00 2001 From: Michael Davie Date: Sat, 29 Apr 2023 01:17:52 -0400 Subject: [PATCH 042/197] 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 17e0ed6e2ac..6262a28302f 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 027dc8c639f..81f8cef959c 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 76dcd3d9fac..b91d6d472c9 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 2cb665a1d99724bf4806c84330230606784c9dc5 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 29 Apr 2023 00:57:30 -0700 Subject: [PATCH 043/197] 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 aedfafbf368..0f047bf3758 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 89eca22b938958a5110985ea1e10f59a65354d71 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 29 Apr 2023 14:02:34 +0200 Subject: [PATCH 044/197] 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 71200b93f3f..f5b97a7fb13 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 46b5773bfaa..30c84c56f00 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 4b9355e1ca475fe2d7ff9becbdedc81397c64ed8 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 29 Apr 2023 17:47:04 +0200 Subject: [PATCH 045/197] 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 7b2321e172e..9b1e2250a28 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 5ac03aea13d..26df432a270 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 379db033afc2c8eadfaa6d350131ad67395e1033 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 046/197] 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 a8a1744c95e..4fdcd0a8bdd 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 81f8cef959c..8b669bff207 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1374,7 +1374,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 b91d6d472c9..3004af5b46f 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 546c68196eed5c5feb234801c56bfb999e077555 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 29 Apr 2023 11:59:44 -0500 Subject: [PATCH 047/197] 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 b0741c44406..afa7f2b5d4b 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 915c51b6c0a..90a2d5167c5 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 8b669bff207..c12eb879813 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2158,7 +2158,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 3004af5b46f..bf44c3ab107 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 2d6ab9937a3..2a0a0eb0655 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 3bab40753d8efe44097ea0139eda22560a37e9e9 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 29 Apr 2023 19:03:08 +0200 Subject: [PATCH 048/197] Bumped version to 2023.5.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 0de53b959ba..719bf450f43 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 = 5 -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 3a33ca2a3c6..73bb83d7a1c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.5.0b2" +version = "2023.5.0b3" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From a5241b311823c94b466063d0278dd5c6e15d52ff Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 29 Apr 2023 14:32:57 -0500 Subject: [PATCH 049/197] 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 dcc98f78a77..15e1192993a 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 725c728607b..592e8f5a1f0 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 6a6eba1ca348dca5c041bdf952ce5c0d259ebb2c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 29 Apr 2023 14:33:25 -0500 Subject: [PATCH 050/197] 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 6f03af3629b..978473caa24 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 c632d271971d95c06574991e08a6bda2fabaa75a Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Sat, 29 Apr 2023 14:24:56 -0500 Subject: [PATCH 051/197] 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 2eedfcdcf9b..3fcc2336aa3 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 19b9806e41e..6ccfae904e8 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 fe452452e6895cff4c2d693e6d237972a87a7636 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Sat, 29 Apr 2023 20:20:43 -0400 Subject: [PATCH 052/197] 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 c30ea1c0a65..8023b9222a0 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 00000000000..05abae4c705 --- /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 369557ad3e9..248622d3157 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 8cf1ed81a84aa337ba3bdfd6888ab156debb0634 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 30 Apr 2023 00:01:44 +0200 Subject: [PATCH 053/197] 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 58260833559..d3806044fcc 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 a8539b89e840efe72d1c58694fc80e682bc609c8 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 054/197] 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 fa35b240d98..821b53f7e12 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 24b851c18497e6886de2ef0bca345595718818f2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 29 Apr 2023 20:17:09 -0500 Subject: [PATCH 055/197] 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 e32cbd4df7f..3cc2e74f95b 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 ec05eafd140..aa036f33999 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 258e15cbb52..3c0daef452d 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 9b4687cb72d..607935bd6ff 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 b19ff4ca503..1fd5d769c7c 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 2e37001582e..9b90489d7c0 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 dfe036355aa..10d1ed00b5b 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 510f46f98a2..ad2c33bfb88 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 ec15a0370684d908a28b0864f9d9af644eba3564 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 29 Apr 2023 20:17:56 -0500 Subject: [PATCH 056/197] 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 181b3321c53..68a4ce52511 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 07f2e6fb7ac..55413e4bf6c 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 1dfcc85cd28..18de9839e1b 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 805b5d85db8..21ef1cf3fc2 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 eb586c71447f4b8b217f248f10694e42c1e50c95 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 29 Apr 2023 21:23:22 -0400 Subject: [PATCH 057/197] Bumped version to 2023.5.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 719bf450f43..9c67ed7e935 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 = 5 -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 73bb83d7a1c..3ab91defeab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.5.0b3" +version = "2023.5.0b4" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 093d5d6176a8dc7cb186cd8d4fa4614533a2883d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 30 Apr 2023 11:08:12 -0500 Subject: [PATCH 058/197] 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 687afdab4c7..ad9045a3d0d 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 d77a61a0659..b11550dccd7 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 1233cf07c22..4e5f8354a4c 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 c12eb879813..e21d62f1cfb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2688,7 +2688,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 bf44c3ab107..9d8570fd2ff 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 ddf5a9fbcc75a4c93d65371b32cdeae9ff9dd602 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 059/197] 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 be14b57ed47..6386a70d08b 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 e21d62f1cfb..7f749bf1d77 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1812,7 +1812,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 9d8570fd2ff..e0a214e15a1 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 fe279c8593e9cdf4e9d2f26f6f8e5344a4ca7502 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Sun, 30 Apr 2023 09:15:57 -0400 Subject: [PATCH 060/197] 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 423be8143b8..c8807d40cc1 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 7a90db903bbe638ddd34f7a45275b03ab964348e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 30 Apr 2023 13:13:47 -0500 Subject: [PATCH 061/197] 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 d49201f6d7b..cdf45db035c 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 15e1192993a..b41fbec3db0 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 7f749bf1d77..303eedc2826 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 592e8f5a1f0..f3479d47789 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 c4aa6ba262dd261a35ab795d26c60920e6d33f3a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 30 Apr 2023 11:06:09 -0500 Subject: [PATCH 062/197] 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 075e8beb789..dba5d35ab1a 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 303eedc2826..f090011a0aa 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 00a28caa6d036f3920668fb6ce47f8da4e58cc88 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 30 Apr 2023 10:07:00 -0500 Subject: [PATCH 063/197] 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 512d8d4ab99..c595a0a2cb9 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 b41fbec3db0..1f4f463bbeb 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 f090011a0aa..c463af1d8f6 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 e0a214e15a1..232b7962e1c 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 5bd54490ea4bf01b074ff9a254ac48a9da7f173d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 30 Apr 2023 11:06:38 -0500 Subject: [PATCH 064/197] 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 36e0bbbc66b..a3b11fab196 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 2b2be6a3332c250b069a0fe6104442368614cce4 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 30 Apr 2023 17:35:24 +0200 Subject: [PATCH 065/197] 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 01471b13bc7..3d19de74f91 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 48e6b4421d5..da2f23bc49c 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 05530d656aa2e84572fa737faa9bb076f17ca304 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 30 Apr 2023 20:16:39 +0200 Subject: [PATCH 066/197] Bumped version to 2023.5.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 9c67ed7e935..b7e7b61081a 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 = 5 -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 3ab91defeab..6bf2166e4fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.5.0b4" +version = "2023.5.0b5" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 0ba662e7bc8a6aff38ec3ebeccbe074b4cc6bc18 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Mon, 1 May 2023 15:42:27 -0500 Subject: [PATCH 067/197] 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 9ea202e3b57..f29705cf41b 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 2c9649d911d..3af15bd2c0b 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 8288297d8ef..b4ee5d8ce7a 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 83931d42c57..2bef9a18008 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 3fcc2336aa3..ddf40f5918e 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 80d8eaa11c3..0bdcc55bfd8 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 @@ -27,7 +27,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 9b3420775c2..f7b3595699c 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 00000000000..975b8f326d9 --- /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 6ccfae904e8..aec9122fae1 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 2a5f5ea03933d23dece5212ead656d8d190e2f9a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 30 Apr 2023 22:18:00 -0500 Subject: [PATCH 068/197] 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 1d2b56fb434..b8436da97d5 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 06dd20defbd..57e572a49c7 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -2314,7 +2314,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 @@ -2330,7 +2330,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 8cbc69fc9230f2d26ea8cf78dbd44672df2a8636 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 30 Apr 2023 22:12:01 -0500 Subject: [PATCH 069/197] 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 ec894befaea..2c96b79cbeb 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 030b7f8a37684352972ddb882ed9503bfd4ce278 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 30 Apr 2023 22:11:29 -0500 Subject: [PATCH 070/197] 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 d08a3b45b34..85190e25f4a 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 af8394e2ad7..97eb337731a 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 1f4f463bbeb..1f708d4438a 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.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 c463af1d8f6..bf2dcb082de 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.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 232b7962e1c..10b96d68a25 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 1e9d777201c2b950c22afaf1bf31cb3f6ee923b3 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 1 May 2023 16:17:01 +0200 Subject: [PATCH 071/197] 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 92b640580eb..dd5480450e2 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 23be7735c3d..7cbcbe73efa 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 eb0e9c9c46b..2a8ea80580b 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 81d8cd9900c..3dd0990b241 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 086469afb29..a8e590a9760 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 eba201e71b1f949a76a106f22eab57e9f7e1f543 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 1 May 2023 10:20:37 -0400 Subject: [PATCH 072/197] 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 8b691e855e3..5da7a97ec24 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 7f13033f69d82b08c46a2e82ff8be83f04539064 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Mon, 1 May 2023 12:32:40 -0400 Subject: [PATCH 073/197] 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 dda9412b56f..52c1f6a5b19 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 4e2d2265178..83799147bbe 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 a45d02a3aa2..4ccf7323148 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 c7eac0ebbb8f2e09a8539dc1be72142cfd40cd11 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 1 May 2023 11:33:52 -0500 Subject: [PATCH 074/197] 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 57478031165..adb6fa89059 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 a3b11fab196..4c2efabf61a 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 41d5164452f..17e7f1f0f29 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 bf2dcb082de..04ce3adb64a 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==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 10b96d68a25..706a49e8a4b 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 7077d23127fafce7230d9914a5dae28c76de48bc Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Mon, 1 May 2023 15:43:27 -0500 Subject: [PATCH 075/197] 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 b9439ee682c..2842e494e7e 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 04ce3adb64a..8389a5e9825 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2594,7 +2594,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 706a49e8a4b..07ebc3d6479 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 6b77775ed59128981117f1dc7c2f71e1c4152d13 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 1 May 2023 22:49:38 +0200 Subject: [PATCH 076/197] 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 e925e68b573..a6eb045f61c 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 1f708d4438a..4bcaa836c8f 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 8389a5e9825..64e138c1370 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 07ebc3d6479..016eba00a30 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 43a1eb043b7daf531537e015baf8cca8a5b19b05 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 1 May 2023 22:55:49 +0200 Subject: [PATCH 077/197] Bumped version to 2023.5.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 b7e7b61081a..f8eff1d2006 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 = 5 -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 6bf2166e4fb..fa85fbb6845 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.5.0b5" +version = "2023.5.0b6" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From eef95fa0d4569451b79f7a6320ee73305c98fe46 Mon Sep 17 00:00:00 2001 From: John Pettitt Date: Tue, 2 May 2023 07:50:34 -0700 Subject: [PATCH 078/197] 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 049b86e1064..cfe1a12a24f 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 2f3964e3ce9ab80038f228fb306881e771e91424 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 May 2023 01:46:14 -0500 Subject: [PATCH 079/197] 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 4bcaa836c8f..138df530f3c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -47,7 +47,7 @@ requests==2.28.2 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 fa85fbb6845..d9aabafa34d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,7 +50,7 @@ dependencies = [ "pyyaml==6.0", "requests==2.28.2", "typing-extensions>=4.5.0,<5.0", - "ulid-transform==0.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 3f24d2897ce..425e82d4311 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,7 +24,7 @@ python-slugify==4.0.1 pyyaml==6.0 requests==2.28.2 typing-extensions>=4.5.0,<5.0 -ulid-transform==0.7.0 +ulid-transform==0.7.2 voluptuous==0.13.1 voluptuous-serialize==2.6.0 yarl==1.9.2 From 7c651665c51b49f984cabe6a24b9923b5e0a1c70 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 080/197] 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 4704718c804..686a186a7cb 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 b3255a76f7e..a43482e3e90 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 0db28dcf4d923e8b06aba6b41baf263567cb1780 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 May 2023 05:17:01 -0500 Subject: [PATCH 081/197] 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 adb6fa89059..78e745645c5 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 5b1278d8855aa266dd0ae34799a5030e8bc64446 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 2 May 2023 22:08:09 +0200 Subject: [PATCH 082/197] 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 cdbea2ca346..f1c4ad729c6 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 eb23b09627e..ee9ef61787b 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 4dd154ea11f..7aa929abf2c 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 24229507877..6c2da5c01c1 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 9be7381adb6..5c7dd4d1402 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 a189c364c02..b9e1426bbc1 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 9c691ebed55..06d6589204b 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 29b9c62ea1d..4c8ebfbd9e9 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 f156acfd568..b27a6ebee02 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 d347140af2e..d57de76f5e0 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 e194242df91..d192b2514de 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 84d5e4a3364..4aadc9c4002 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 737b54c8b1e..c0a65cbfa7a 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 1b1b443baac..798743a447d 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 987a4317ba8..45646b72b7f 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 b246c26e91c..9217e073fe4 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 ef64a86c6e8..6e6dffd2337 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 a73271bdc83..d2e3995b85e 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 601f59fd118..36cc005bf2f 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 f0202e3d958..8d37eba219a 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 8001411ac71..46a778f5e31 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 435d1378e84..b6acf375f2e 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 0e1f941ab64..bf4890e92dd 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 d1e1a8ce112..9bca4b79340 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 8b927f7f3aa..51e8de98301 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 a928be477fb..b1236af89fb 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 6eadb068054..fb455da945b 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 44bb3111987..daba360f7bf 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 4d49b2d21ea..923052f9f81 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 96032c12018..a6182289a86 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 9b73957ef71..5d294ec3bba 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 90cf2f59ee0..f60a06cb4c5 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 2122818bbb4..29ad5e6710a 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 3f7b536f163..5f033319c44 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 793db076c79..cda3ded25be 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 b7dc880ede0..5297d6b29e5 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 28b7080b730..ff673ddfe24 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 e26e98598e6..3aefbfacdf8 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 5dbe1ee5618..3ca965ec998 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 1aa98ab423f..7b3e627011a 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 3de10491f39..b925fcb341c 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 58babc0ccb0..69e2aa2c8e2 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 773dd171511..0b74763c6a7 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 2bb9a4972a3..5a1d42352fe 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 fd77499ff09..0374f3f1e94 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 c139ff4cf4a..2c85dac0bd4 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 5b382f2540d..d996a67f93b 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 4fb6f60e0f6..903d24d39bb 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 a77ee314088..2254abc08f9 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 f722292fc89..d324f7a0c54 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 fac744d0c0e..ad7bf732dcc 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 f695ce11117..54134ee501a 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 ca147dd97e0b2300c229c1faf47cf813a12a2ce6 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 2 May 2023 22:41:35 +0200 Subject: [PATCH 083/197] 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 a6eb045f61c..8167b9bbb17 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 138df530f3c..96257ec62ea 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 64e138c1370..6346ef77e39 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 016eba00a30..e46df71faf0 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 cdfd53e1cce669e48f32c5ba3b2db12f277ae618 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 2 May 2023 22:44:32 +0200 Subject: [PATCH 084/197] Bumped version to 2023.5.0b7 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index f8eff1d2006..d920cd97ddd 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 = 5 -PATCH_VERSION: Final = "0b6" +PATCH_VERSION: Final = "0b7" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 10, 0) diff --git a/pyproject.toml b/pyproject.toml index d9aabafa34d..94f7a2092ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.5.0b6" +version = "2023.5.0b7" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From b8eebf085cabb88c2c754b425084927ee65a2ffc Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 2 May 2023 22:38:54 -0400 Subject: [PATCH 085/197] 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 9217e073fe4..81b3a60b3f5 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 00000000000..2f9d0b8017f --- /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 7b3e627011a..08e0050ef81 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 5f3bbf280493280327b59ba9df789a755328f8eb Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 2 May 2023 22:39:38 -0400 Subject: [PATCH 086/197] Bumped version to 2023.5.0b8 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index d920cd97ddd..11284d086ba 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 = 5 -PATCH_VERSION: Final = "0b7" +PATCH_VERSION: Final = "0b8" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 10, 0) diff --git a/pyproject.toml b/pyproject.toml index 94f7a2092ad..80779d15955 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.5.0b7" +version = "2023.5.0b8" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 51a10a84da3c8a5c407db9c4bde621c6aa135e0a Mon Sep 17 00:00:00 2001 From: Artem Draft Date: Wed, 3 May 2023 09:52:56 +0300 Subject: [PATCH 087/197] 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 c5b42e73bee..5a0a9def0ae 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 6346ef77e39..52bbc9f0d34 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1533,7 +1533,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 e46df71faf0..bef149eab40 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 88343bed77274e80c0f8f7fbbfaa7f110806cd6b Mon Sep 17 00:00:00 2001 From: repaxan Date: Wed, 3 May 2023 05:35:20 -0700 Subject: [PATCH 088/197] 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 4080e95748a..ab58405b974 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 c31d65720665a73bd49cc938d599a7066a560d0b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 3 May 2023 10:49:01 +0200 Subject: [PATCH 089/197] 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 08e0050ef81..4f9a78625db 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 10508956575ccbcb6f6f5616faeaa619a3151625 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 3 May 2023 12:39:22 +0200 Subject: [PATCH 090/197] 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 f1c4ad729c6..cdbea2ca346 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 ee9ef61787b..eb23b09627e 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 7aa929abf2c..4dd154ea11f 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 6c2da5c01c1..24229507877 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 5c7dd4d1402..3a702421d94 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 b9e1426bbc1..a189c364c02 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 06d6589204b..212cbb26e0a 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 4c8ebfbd9e9..a5700789112 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 b27a6ebee02..f156acfd568 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 d57de76f5e0..d347140af2e 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 d192b2514de..e194242df91 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 4aadc9c4002..84d5e4a3364 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 c0a65cbfa7a..737b54c8b1e 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 798743a447d..1b1b443baac 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 45646b72b7f..987a4317ba8 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 81b3a60b3f5..56df611b323 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 6e6dffd2337..ef64a86c6e8 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 d2e3995b85e..a73271bdc83 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 36cc005bf2f..601f59fd118 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 bf4890e92dd..257d04cc697 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 9bca4b79340..d1e1a8ce112 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 51e8de98301..877a6efaf05 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 fb455da945b..6eadb068054 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 daba360f7bf..44bb3111987 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 923052f9f81..4d49b2d21ea 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 29ad5e6710a..2122818bbb4 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 cda3ded25be..793db076c79 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 5297d6b29e5..b7dc880ede0 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 ff673ddfe24..6455128fce8 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 4f9a78625db..db82a696155 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 ad7bf732dcc..fac744d0c0e 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 ad4fed4f60d35616029de81166a05f31733b0847 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 3 May 2023 15:45:54 +0200 Subject: [PATCH 091/197] 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 7ba12ec830d..a8828fcc0e9 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 d347140af2e..8a5ef7b294e 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 56df611b323..159b54cb5a8 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 3ed0ff8d472..d8c5a6c1cf6 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 c2c18a6ed09..faaebda0aae 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 601f59fd118..a1f77a9b49b 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 b8a1c52f473..3859e0c857c 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 00000000000..4d6b5adfde7 --- /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 4adb580a83e..d99a50bd286 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 65145f9d3be..8953158e423 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 c6596e72251..df2b8cbe737 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 44bb3111987..ced9d9cc5c8 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 4d49b2d21ea..f13d3cda3e1 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 @@ -1351,17 +1304,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") @@ -1409,17 +1353,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 555942941eb..67bc24909c5 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 25b9c5e89a2..4c6497b975b 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 837f98ad4b9..884107b7eb9 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 1ce876ee584..9fcd36d06f3 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 0c35da4d3cb..a59761d1c74 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 eb1e52bf335..b7bf35ab2f8 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 31bf9e63dbf..98922d7d0a4 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 5f61192033c..a33d9fcfdec 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 835341f0757..349440124ff 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 85a0ee39105..6581aea835f 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 201c331a2e7..dfda0b0d282 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 a6d2d34b178..27656dd10c7 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 241a1c60fbd..9b297bf884f 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 07fe2aee02c..5961b925a2a 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 37fcbd7c7b3..be528459a70 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 fbf4e576dd5..a6cf342eeb3 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 f5d031f0f6c..5d7928124dd 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 8ef6bd85f52cb7a5c54cc83f28199dacbdcf81ec Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Wed, 3 May 2023 08:35:53 -0400 Subject: [PATCH 092/197] 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 2063a9906ec..9407dc84147 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 52bbc9f0d34..e54fbf939b7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2718,7 +2718,7 @@ zeroconf==0.58.2 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 bef149eab40..268174b847d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1964,7 +1964,7 @@ zeroconf==0.58.2 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 b87e3860d90cb7717631a2222f95c682b3220cb9 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 3 May 2023 15:46:14 +0200 Subject: [PATCH 093/197] 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 8167b9bbb17..81aa690123e 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 96257ec62ea..8f546022ec9 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 e54fbf939b7..0214e0b36d4 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 268174b847d..6447802365a 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 c6751bed86da41de3f747800a880beb58a544e23 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 3 May 2023 15:55:38 +0200 Subject: [PATCH 094/197] 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 a5700789112..03dd27c7c38 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 8bc31f7b862..391726cb900 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 159b54cb5a8..07f14e7ce8c 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 257d04cc697..134838ff1ce 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 d1e1a8ce112..534456896b4 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 877a6efaf05..c6e08f9e152 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 ff4b9be4d3f..f497c2c108e 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 6eadb068054..df57c78c9aa 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 6455128fce8..f9ea356216f 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 db82a696155..fd09bcee45a 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 44968cfc7c6e28f94e8a78273842ac2fa6506330 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 3 May 2023 08:46:53 -0500 Subject: [PATCH 095/197] 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 4c2efabf61a..851b0f26d1b 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 387f07a97fd46ac229d299be5afb992e666883ea Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 3 May 2023 16:14:04 +0200 Subject: [PATCH 096/197] 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 391726cb900..b0878fd24aa 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 f497c2c108e..3e7523cc02c 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 9d0fc916fccc25ae300eb4110048cf7b6b11506c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 3 May 2023 16:50:43 +0200 Subject: [PATCH 097/197] 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 134838ff1ce..4d1f4d457df 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 c6e08f9e152..0738bc6b029 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 3e7523cc02c..8911b34055a 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 820c7b77cea661ab0a36afce78179e0e15dcb3c5 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 3 May 2023 17:06:42 +0200 Subject: [PATCH 098/197] 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 212cbb26e0a..6645c4f9f60 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 b0878fd24aa..f5d5c98fe1a 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 8911b34055a..ff79fd1ea77 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 b558cf8b597fa7cb593b29ac51682741f403d939 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 3 May 2023 17:27:42 +0200 Subject: [PATCH 099/197] 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 81aa690123e..6489b150e79 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 8f546022ec9..4f0d768c943 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 0214e0b36d4..6efe87acf1d 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 6447802365a..04eec8d9502 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 4f0d403393fe53c3cc99c4e6f525f58153a114f5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 3 May 2023 11:18:47 -0500 Subject: [PATCH 100/197] 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 c595a0a2cb9..84a86754ce6 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 4f0d768c943..4d2419d30c3 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 6efe87acf1d..027b1e9c64d 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 04eec8d9502..852b1857d95 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 3cd2ab2319598e81b778842686269fc88bc89ddd Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 3 May 2023 18:39:27 +0200 Subject: [PATCH 101/197] 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 6645c4f9f60..bfdd2e560a5 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 4d1f4d457df..2a4be7e1645 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 2cd9b94ecbe744e5e9eff128dfc00e46285fe87a Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 3 May 2023 11:18:31 -0500 Subject: [PATCH 102/197] 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 8a5ef7b294e..dccf394ab3f 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 6bc3d88287f..2f5ea26a8a6 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 7a4ca862ee2..8b07c2adc9a 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 ced9d9cc5c8..58fe9371e11 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 0251d677d8f5252feb7533ad85457628e485cd31 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 3 May 2023 18:56:48 +0200 Subject: [PATCH 103/197] 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 03dd27c7c38..dae1c00a33f 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 0738bc6b029..0fa37ed9987 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 7a62574360b83365dae507b8d310a5d043c8d8aa Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 3 May 2023 18:59:42 +0200 Subject: [PATCH 104/197] Bumped version to 2023.5.0b9 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 11284d086ba..4b67ad19ba1 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 = 5 -PATCH_VERSION: Final = "0b8" +PATCH_VERSION: Final = "0b9" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 10, 0) diff --git a/pyproject.toml b/pyproject.toml index 80779d15955..8ee88e94c9a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.5.0b8" +version = "2023.5.0b9" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 576f9600b5bce0e4ce74bdd4250d81c2984548bf Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 3 May 2023 12:43:14 -0500 Subject: [PATCH 105/197] 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 ddf40f5918e..8b96941e00a 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 aec9122fae1..bd9a3587a9a 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 15fdefd23bc487c20005dc3e6415bcf8bffea1d9 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 3 May 2023 19:44:53 +0200 Subject: [PATCH 106/197] Bumped version to 2023.5.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 4b67ad19ba1..3eb31a1af78 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 = 5 -PATCH_VERSION: Final = "0b9" +PATCH_VERSION: Final = "0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 10, 0) diff --git a/pyproject.toml b/pyproject.toml index 8ee88e94c9a..ae07e6d7672 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.5.0b9" +version = "2023.5.0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 458fe17a48e25b07c85c820f1342930824e63970 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 2 May 2023 15:39:41 -0500 Subject: [PATCH 107/197] 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 2842e494e7e..345480da363 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 027b1e9c64d..4b431dc87e9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2594,7 +2594,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 852b1857d95..222be3a03ac 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 fffece95f59d389164868201d5d5e950ef164a83 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 May 2023 04:50:12 -0500 Subject: [PATCH 108/197] 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 78e745645c5..2e0f1ca8c6f 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 0cfa566ff631eb974b0c8a5d9e5239282e270102 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 May 2023 06:48:13 -0500 Subject: [PATCH 109/197] 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 2e0f1ca8c6f..f93529ea612 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 89aec9d356b4017f6db5a7cd4069413f99eab40d Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Thu, 4 May 2023 04:21:58 -0600 Subject: [PATCH 110/197] 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 7eb2ef6bba3..1c3ffc8607a 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 4b431dc87e9..e1124846d44 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 222be3a03ac..18691a914de 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 3126ebe9d6488266febd1815d1ac8847066b083e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 May 2023 07:55:47 -0500 Subject: [PATCH 111/197] 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 66cea18f119..f8964e78f63 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 dd4e50d8f16..227d279f07b 100644 --- a/homeassistant/components/lifx/light.py +++ b/homeassistant/components/lifx/light.py @@ -382,7 +382,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 42c540a74ef..fe68bd6547a 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 a07fbdd61cc5c8efce7cc79aa636192fef39b624 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 May 2023 08:53:43 -0500 Subject: [PATCH 112/197] 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 84a86754ce6..fb4cc002598 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.85.0" ] diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 4d2419d30c3..4d861c9f39d 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 e1124846d44..883c055a7a8 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 18691a914de..fa9d55d2992 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 4b4464a3deae5664e7649d74ae6e2a62da5b08f6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 4 May 2023 15:53:28 +0200 Subject: [PATCH 113/197] 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 bfdd2e560a5..4ba32c338b5 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 dae1c00a33f..16848acc19d 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 2a4be7e1645..3a7e5a0874e 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 0fa37ed9987..45bc56a1700 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 238c87055fe70c9ff94d8b4295e951c0d0365373 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 4 May 2023 16:22:48 +0200 Subject: [PATCH 114/197] 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 6489b150e79..41b363b6388 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 4d861c9f39d..63b89bbe5de 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 883c055a7a8..2cdf860f642 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 fa9d55d2992..048f57d1f3d 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 eda0731e605b2c27c84cc0f8c6cd6d068e3534c4 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 4 May 2023 10:23:58 -0400 Subject: [PATCH 115/197] Bumped version to 2023.5.1 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 3eb31a1af78..badec5be56f 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 = 5 -PATCH_VERSION: Final = "0" +PATCH_VERSION: Final = "1" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 10, 0) diff --git a/pyproject.toml b/pyproject.toml index ae07e6d7672..d3c150305bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.5.0" +version = "2023.5.1" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From affece88575969f3940910c617fb13290acc3117 Mon Sep 17 00:00:00 2001 From: DDanii Date: Fri, 5 May 2023 08:42:51 +0200 Subject: [PATCH 116/197] 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 765755d1248..d8623e7bbe5 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 d163708ce28..b4fae8e6f3d 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 da5e6859544..89ad0dd2410 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 d96b37a0047ccce9dfca8de141013af2c399df24 Mon Sep 17 00:00:00 2001 From: Francesco Carnielli Date: Thu, 4 May 2023 17:36:31 +0200 Subject: [PATCH 117/197] 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 25c42f92cef..949c7336ea4 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 b2fcbbe50e16ebb7757ce7bf7db67b00f1322616 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Fri, 5 May 2023 10:47:49 +0200 Subject: [PATCH 118/197] 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 6a86ce81445..ef2ecc7aa23 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 715fa26eee9..db0845473fd 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 b973825833474075aaddf7ca4b09a8eb27570d9f 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 119/197] 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 cbe5e70f688..202b4a98aa9 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 e3762724a3516f69e6956b06c153a55bd7332a7b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 May 2023 12:05:29 -0500 Subject: [PATCH 120/197] 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 cacf317f7bd..f263821a460 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 68a4ce52511..27f279266dd 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 f93529ea612..ea2325f271c 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 851b0f26d1b..92f76b6a950 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 17e7f1f0f29..9fc0d417838 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 2cdf860f642..143377025d0 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==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 048f57d1f3d..114b394ca48 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 18de9839e1b..a56e0a477e7 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 4c2dda760e4..4b30bc7bdd1 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 8a11ee81c41e7327199fa2bc802184f12468304b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 5 May 2023 05:10:43 +0200 Subject: [PATCH 121/197] 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 cdbea2ca346..159bfebc624 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 4ba32c338b5..b7f0b5f6763 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 16848acc19d..02aa5760597 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 241cacde62ec9b80e243c9e3aaac62b9aa314329 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 May 2023 21:18:20 -0500 Subject: [PATCH 122/197] 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 3576dadd1c0..ff78996f3aa 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 143377025d0..05145ca6a3e 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 114b394ca48..8a85ca7465c 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 2dd1ce204701463ecbf394d3443aaaa19059aa42 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 123/197] 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 d134c033ed7..53a3fb883ef 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 c0c455542d3..1897383b6c4 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 163823d2a52370c8dd95d44236646196a54e4878 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 May 2023 21:21:42 -0500 Subject: [PATCH 124/197] 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 61d6262250c..7ce195d68fc 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 82c0967716fdbf71c298878d44de4824b8af861a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 May 2023 21:20:25 -0500 Subject: [PATCH 125/197] 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 26fab34f0e1..d7094a2e60b 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 05145ca6a3e..d045d6adf7c 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 8a85ca7465c..a98888f3f91 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 e8808b5fe7f00f6566a8e4009ae85be3812810de Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 5 May 2023 08:11:09 -0400 Subject: [PATCH 126/197] 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 b7f0b5f6763..53bf44d8aa1 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 02aa5760597..351de5d0e65 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 75e1856503c..5ccc007e524 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 3a7e5a0874e..2be2a8eb2bb 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 45bc56a1700..fe60ca971a1 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 f8c3586f6bf41b45029024932fca169d708ce265 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 127/197] 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 78d974fe9cf..42a51c218b1 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 fb29e1a14e5971c04821356fcc025a9acecd5c6b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 5 May 2023 14:40:30 +0200 Subject: [PATCH 128/197] 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 c9c135fcccb..a5a8ed2f0d2 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 d045d6adf7c..1bfc913792d 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 a98888f3f91..31d94ed1107 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 7eee8fcbe7c..1d9334a2657 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 15ef53cd9ade48ce7e847eace14bde7a9508a393 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 5 May 2023 08:47:12 -0400 Subject: [PATCH 129/197] Bumped version to 2023.5.2 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index badec5be56f..7c9681ff2b4 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 = 5 -PATCH_VERSION: Final = "1" +PATCH_VERSION: Final = "2" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 10, 0) diff --git a/pyproject.toml b/pyproject.toml index d3c150305bd..20a02528aff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.5.1" +version = "2023.5.2" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 35c48d3d0ec82e68dd27a1ea4bc829ceab368624 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 May 2023 13:26:58 -0500 Subject: [PATCH 130/197] 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 92f76b6a950..35df9221934 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 9fc0d417838..f29fd562104 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 978473caa24..a88a37f5d20 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 1bfc913792d..5086fd1cd84 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.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 31d94ed1107..567ff424820 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 cf243fbe1117b36f4d5cd8eadd28461c4a43aa71 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 5 May 2023 20:27:28 +0200 Subject: [PATCH 131/197] 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 4c96f2575f0..03e242f40b2 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 f1bccef224e8cde1aacde6d721e269119d99fd0b Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 5 May 2023 20:27:48 +0200 Subject: [PATCH 132/197] 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 41b363b6388..4e1e0a74fe9 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 63b89bbe5de..a30652fac8d 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 5086fd1cd84..5e3d4d52d5c 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 567ff424820..6762e481d02 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 73d4c73dbb73555559f6c66ba6b9f88a85963403 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 May 2023 13:38:36 -0500 Subject: [PATCH 133/197] 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 35df9221934..507eda60097 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 fe57901b5f7c5be935ea33cc4ab18a436ee3dc72 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 May 2023 05:19:27 -0500 Subject: [PATCH 134/197] 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 443254e125a..abb1f114ce5 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 ddebfb3ac5a97fe4c4eba4989698c0faa9ac0bb5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 May 2023 13:32:55 -0500 Subject: [PATCH 135/197] 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 abb1f114ce5..8e6e3e25861 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 ac9da5c1673fd60ad244773a76c5d5217159702f Mon Sep 17 00:00:00 2001 From: Luke Date: Fri, 5 May 2023 15:20:30 -0400 Subject: [PATCH 136/197] 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 497d30b41cf..1ea5e4734bb 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 cbd5ef379e8..55eb8086842 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 05bf0848475..18d9ee1bafe 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 dd51bba6779329704db64b406d2e3867881514e4 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 137/197] 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 3c7d2ba27c3..afabcbd3df4 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 5e3d4d52d5c..22942538fa4 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 6762e481d02..eaa115058c8 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 dcc5940f9b8ed35269c71983a2f0a7fdc174a617 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 6 May 2023 09:46:00 -0500 Subject: [PATCH 138/197] 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 9dbd5d4ad67..00171350594 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 bb95860142d..40c402f6f49 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 96ff24aa2fd5e93f5e2a2c411430f5e4a867c18b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 May 2023 19:02:32 +0900 Subject: [PATCH 139/197] 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 f8964e78f63..e668a7ad79a 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 fe68bd6547a..67bf2754d11 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 996c6c4a92e41638f830852442139d268475238b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 6 May 2023 17:12:24 -0500 Subject: [PATCH 140/197] 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 2c96b79cbeb..a7c23064f64 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 27f279266dd..ca447c71b84 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 55413e4bf6c..3e9db0b3c7e 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 21ef1cf3fc2..8187a427be9 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 91e9d215482f91dc2bb6f38e64c8778f93052dc3 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Sat, 6 May 2023 12:11:57 -0600 Subject: [PATCH 141/197] 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 1c3ffc8607a..f0952089ba8 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 22942538fa4..3812de4de3c 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 eaa115058c8..03cf69f06ac 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 4895ca218f5e17ce1c838f24e07d4bbfd83c8b74 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Sun, 7 May 2023 00:36:21 +0200 Subject: [PATCH 142/197] 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 caa4f6c3868..dfd4a6c28e1 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 3812de4de3c..b5da28469e7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1859,7 +1859,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 03cf69f06ac..967ce1b8674 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1357,7 +1357,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 b1111eb2c7e01e7c437a5e1e14df231269e7791f Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Sun, 7 May 2023 16:33:15 +0300 Subject: [PATCH 143/197] 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 1eac959c169..9152739852e 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 b5da28469e7..b3875a3306a 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 967ce1b8674..dfe9bae11a8 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 d4acb2a3818aea5acbd5bfbb9f3d3fb3358a0dc5 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Sun, 7 May 2023 16:47:02 +0200 Subject: [PATCH 144/197] 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 befa2c5df92..a59d1f1cdad 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 fb8d20e1830..64ca3832ce0 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 7173a4f377912c1f76e3cf70c35faf64af0c416c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 8 May 2023 11:56:06 -0500 Subject: [PATCH 145/197] 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 ff78996f3aa..0e9715038f0 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 b3875a3306a..cccf27b4161 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 dfe9bae11a8..6a95345d4db 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 8d0da78fab61f27b5a798dd96704c6cc81cde4ed Mon Sep 17 00:00:00 2001 From: Brandon Rothweiler Date: Sun, 7 May 2023 11:32:11 -0400 Subject: [PATCH 146/197] 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 c9adac23186..bb92496b74f 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 f866d6100dff59e282a7ff3fd73ce091f0662b47 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 147/197] 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 de9d4842ff7..b9209c6904f 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 16020d8ab97c5c8744e192b0001a516a7ed949de Mon Sep 17 00:00:00 2001 From: Keilin Bickar Date: Sun, 7 May 2023 14:58:14 -0400 Subject: [PATCH 148/197] 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 8b6deaa3c7a..3d757e2328d 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 cccf27b4161..63495a07376 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 6a95345d4db..e6fd55a8f58 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 5c949bd86263124741d5a416c9c1f28c0d3ae3f9 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 149/197] 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 08d7fb1aced..a99d0ffd793 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 63495a07376..644ee030eb7 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 e6fd55a8f58..ed89952840c 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 84ce2f13f2757bc7b04c70cabe7944c8719a05a6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 9 May 2023 19:58:00 +0200 Subject: [PATCH 150/197] 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 159bfebc624..e086d525cf1 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 00000000000..9536e3d471b --- /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 a551de06c79dddffea604e16c2c92e95ddc3416a 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 151/197] 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 c344b1ff49c..1a167f49f78 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 860bc50e93c..8033871f5c3 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 640826bb30f..caf8cfe13bd 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 1e7d335a46f..c72c083039e 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 6277c077c00..bbbe00a431b 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 7361c29cba9b1317c941b4f712c9261196c5edba 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 152/197] 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 a99d0ffd793..991584dd8f8 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 644ee030eb7..f2cb76208a3 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 ed89952840c..d98fbb439f7 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 3c45bda0e891826eb67d666722a65c0bc84af78c Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Tue, 9 May 2023 21:22:06 +0100 Subject: [PATCH 153/197] 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 d199b8808d3..d55a1136646 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 c1b18dcbbac75b08eebb88418ef4fe2cf94e11ff Mon Sep 17 00:00:00 2001 From: jjlawren Date: Tue, 9 May 2023 00:58:56 -0500 Subject: [PATCH 154/197] 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 4a05053940c..087c636f1ed 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 f2cb76208a3..ab7fc27f236 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2390,7 +2390,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 d98fbb439f7..76a391e0be8 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.1.0 +sonos-websocket==0.1.1 # homeassistant.components.marytts speak2mary==1.4.0 From 5e77de35bd787e0c2bd13ab6359716339c290a32 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 9 May 2023 13:46:57 -0500 Subject: [PATCH 155/197] 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 8324293e136..615024e059c 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 70c38a38f00..41f3ef4b301 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 91faa31e8c2775878b09e15f3e267a1d8a56be4c 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 156/197] 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 9407dc84147..1055d846544 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 ab7fc27f236..bdd8b031286 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 76a391e0be8..0be434eaab8 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 8e407334b72ad2c50dcf66f1b9e0ebbae2cdfe3d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 May 2023 17:03:31 +0900 Subject: [PATCH 157/197] 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 d7f2c515308..a802aed5e80 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 a56e0a477e7..598546a6417 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 70dafe960b4..2ab2deb6884 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 252b99f00bb6e96e452ea9da42f780cbc1613e09 Mon Sep 17 00:00:00 2001 From: Glenn Waters Date: Wed, 10 May 2023 12:03:38 -0400 Subject: [PATCH 158/197] 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 3702751ef44..00cebe1e0d9 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 bdd8b031286..ee3aa635195 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2565,7 +2565,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 0be434eaab8..0ecaf509edb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1841,7 +1841,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 7abe9f1f9ad64775f17e9a7ae77f17591a7d24d6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 May 2023 15:58:29 +0900 Subject: [PATCH 159/197] 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 fb4cc002598..4b957674655 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.85.0" ] diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index a30652fac8d..bc0ba680fc0 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 ee3aa635195..6d0f0405adf 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 0ecaf509edb..b9faece1865 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 413dbe89e58c4e21afc577920bdb22116ec53cf2 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 13 May 2023 10:42:04 +0200 Subject: [PATCH 160/197] 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 f34af9ff913..61f59fe06d6 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 60fb71159d31d2e9af36ef4cbea6ab8c7ffca75f 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 161/197] 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 821b53f7e12..60b422eff2f 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 fe308e26dc7487ebb3c73fe272a03cda7be38046 Mon Sep 17 00:00:00 2001 From: Jonathan Keslin Date: Thu, 11 May 2023 00:42:04 -0700 Subject: [PATCH 162/197] 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 99553426ea8..89a35ecde1d 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 6d0f0405adf..989bcc843fa 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 b9faece1865..c7f902a5f6d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1873,7 +1873,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 b0520ccb94c6b2a86807831160215286e7eba576 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Wed, 10 May 2023 20:32:14 -0400 Subject: [PATCH 163/197] 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 e426d5f98b5..684623113db 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -783,6 +783,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 427aa9633c8..c9a5245da41 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 989bcc843fa..01811ea8b0d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -683,7 +683,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 a3f3b43c2096ab330047a7b5b53515259cfe5c3d Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 11 May 2023 21:31:17 +0200 Subject: [PATCH 164/197] 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 5ec3a6ae190..0b5640fe3a4 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 01811ea8b0d..28171ba77fc 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 c7f902a5f6d..6f8405dd0bd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1858,7 +1858,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 a8cf3fadaaf3d30ed440b015be8317fa757d6b99 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 165/197] 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 c17a26794df..ecda3addcb5 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 d840d27f2d1af68f8c19a3e52b580e4f71d41d8b Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Fri, 12 May 2023 16:04:36 +0200 Subject: [PATCH 166/197] 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 65bb8036c0b..3aa5faa527b 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 cad89ac48c1..6a4ae98a154 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 405c3e2716d..9dba3b840ea 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 28171ba77fc..4dda50d6243 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 6f8405dd0bd..eca72a0072b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1611,7 +1611,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 304c34a119a6e1e09d76464b9b81d1d271e32d80 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 167/197] 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 1055d846544..46fe2ce472a 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 4dda50d6243..b74a0062f3f 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 eca72a0072b..82403ad5c49 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 13c51e9c34a8ba9cea1f6b891894f1845f12acd3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 13 May 2023 19:15:02 -0500 Subject: [PATCH 168/197] 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 78a8051df1c..78806cb5ae1 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 6424dee2316a4baf39f8e8a212419a35fda746fe Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 13 May 2023 19:16:11 -0500 Subject: [PATCH 169/197] 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 aa1b933e0ae..664d6f15650 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 ff14277805750105fcd7456c9ab7de839ef698b4 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Sun, 14 May 2023 10:07:15 -0600 Subject: [PATCH 170/197] 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 5e55496fc54..ad228f08a4b 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 bd2de303d2d..f70af18c3e1 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 06100580b39..86b84760016 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 f0952089ba8..168899c38e0 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 f4e6e7cc322..e6ff3eaab69 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 b74a0062f3f..7b6809e9a96 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 82403ad5c49..eba8bdc3f97 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 75eeda70300..81d69158e82 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 008967ece86..05bd8859e7e 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 bd49aab89db..6d59dde76df 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 e042daf6ddd..9f0d0fe2e03 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 00000000000..6fa603e9d85 --- /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 7062778e812..b59b995b404 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 1f6a601fc9de7418fe87b408d37880d71762767c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 14 May 2023 12:11:32 -0400 Subject: [PATCH 171/197] Bumped version to 2023.5.3 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 7c9681ff2b4..05e9808473a 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 = 5 -PATCH_VERSION: Final = "2" +PATCH_VERSION: Final = "3" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 10, 0) diff --git a/pyproject.toml b/pyproject.toml index 20a02528aff..2780f467729 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.5.2" +version = "2023.5.3" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 5b0d53389cab1d74243f23a79000bf7e3ecce03d 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 172/197] 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 46913e90516..f94f9ca8a3a 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 367198a20c601c058375f03588109d05bae18209 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 May 2023 15:08:39 -0500 Subject: [PATCH 173/197] 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 a7c23064f64..36b4a28dffd 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 ca447c71b84..da948787e49 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 ea2325f271c..1152503a718 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 507eda60097..dbff9660b12 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 f29fd562104..a749e59be48 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 7b6809e9a96..3fd92b1077d 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 eba8bdc3f97..e67cf1ce2a8 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.1.1 +onvif-zeep-async==3.1.7 # homeassistant.components.opengarage open-garage==0.2.0 From cd195b7f503a6103e58bb5c27758e2a0d784b34d Mon Sep 17 00:00:00 2001 From: Michael Mraka Date: Tue, 23 May 2023 19:20:04 +0200 Subject: [PATCH 174/197] 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 2a923c3b725..fd0db1be054 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 ce98324da34d44527b211c77e75420d8d92037a2 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 175/197] 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 61f823de8e6..79a4294449b 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 5edae630263..5e36c9c0717 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 c200c9fb4b497cb9e4e89c1b779472fb2ad06d41 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 176/197] 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 8b04f845709..234f5ae4fed 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 a9afccb40652f7812d8f370f94642b9ee30a6228 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 17 May 2023 06:14:31 -0500 Subject: [PATCH 177/197] 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 dbff9660b12..bb42e63c52e 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 fa6834347a3165fb182a9d7f8acef7e757260c16 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 15 May 2023 14:26:29 -0500 Subject: [PATCH 178/197] 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 afa7f2b5d4b..fcb30cdba5f 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 3fd92b1077d..8879badf5c0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2158,7 +2158,7 @@ pytrafikverket==0.2.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 e67cf1ce2a8..a166ace30b3 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.3 +pyunifiprotect==4.9.0 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 From 397864c4979c873e42efa36c06c734965a8cf1d5 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 15 May 2023 21:15:10 +0200 Subject: [PATCH 179/197] 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 666a82c73d4..31d028c0519 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 58bb084296a..8f00cf395d2 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 40c0447292d84b372a0d2b61467fd67fca1b99ec Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 16 May 2023 16:38:17 +0000 Subject: [PATCH 180/197] 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 ad07154ff6b..fbf31720e13 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 8879badf5c0..c2b3810f4b0 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 a166ace30b3..aa9eeb09842 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 fab670434e07f5b15e7c141e3101004e48791907 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Tue, 23 May 2023 10:19:29 +0100 Subject: [PATCH 181/197] 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 d55a1136646..64d83506ad9 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 93da55c51a4..b2ad0b36b68 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 0d432a4dd3f5ed96e500a4b3bbfa682e2a446910 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Tue, 16 May 2023 16:28:20 -0600 Subject: [PATCH 182/197] 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 ff35b24cc97..574ca3d7f43 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 c2b3810f4b0..0d59a403779 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 aa9eeb09842..7ad13045de4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1605,7 +1605,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 94130b7134ad61a912f05bd0d67111910d868652 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 16 May 2023 18:52:18 -0500 Subject: [PATCH 183/197] 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 aa73bcc7ba5..c534c635317 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 0d59a403779..06e21bde9ee 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 7ad13045de4..9f664d2edbf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1115,7 +1115,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 8ebd827667dafd3e47e6912df46a1e20128ad632 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 184/197] 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 0cde37ba6b3..98d312a9836 100644 --- a/homeassistant/components/bmw_connected_drive/config_flow.py +++ b/homeassistant/components/bmw_connected_drive/config_flow.py @@ -15,7 +15,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( { @@ -48,6 +48,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 @@ -77,6 +79,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 50634ebdb96..37225fc052f 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 ae139d4c64a..f31198017dc 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 afabcbd3df4..c600a1529a9 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 06e21bde9ee..781b35d51e8 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 9f664d2edbf..8a2327f5102 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 12957db5cac..b1f1db305b8 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 7ee3f625911..f5966afb32e 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 4db57ad3022..957d69b9eca 100644 --- a/tests/components/bmw_connected_drive/test_config_flow.py +++ b/tests/components/bmw_connected_drive/test_config_flow.py @@ -14,7 +14,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 @@ -25,6 +30,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 9bfd636ade8c20fed68435241100d7879c5242db Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Wed, 24 May 2023 05:32:47 +1000 Subject: [PATCH 185/197] 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 a13fa95f6ba..6170bd165e9 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 65c5e700641529dc28a98b8f2e0a8d51802c1339 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 17 May 2023 06:25:28 -0500 Subject: [PATCH 186/197] 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 4e5f8354a4c..9f766c91df9 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 4a937585732..4e9b7513745 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 381229edead..8aa795b970e 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 781b35d51e8..f2313f95f3b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2685,7 +2685,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 8a2327f5102..39449b6e186 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1940,7 +1940,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 1a0035798bdad3cab2ff2f88266211a1ed4705be Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 20 May 2023 09:42:19 -0500 Subject: [PATCH 187/197] 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 7839d879901..f4e048ecf16 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 1ab2369934c..d380ee11834 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 067f986c4e6..58f1c2fc976 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 5b936e9f159..752499e29e2 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 ffd62cd8fb7..6c19d57a0c3 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 32004158f7f..9dcf96f057a 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 b11550dccd7..9e8b2470b4e 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 9f766c91df9..56965dff850 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 50db556c13a..88362c9fd66 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 f2313f95f3b..4b0e5e85478 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2688,7 +2688,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 39449b6e186..35eb9a1379f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1943,7 +1943,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 241dd36a5e8..f30828a5d72 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 48a1f62875b..c15ccfd0119 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 80f91dc37ee..23ea12a9f82 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 97bbc52c75709da0949d991c2053def42b672f2e Mon Sep 17 00:00:00 2001 From: Steven Looman Date: Mon, 22 May 2023 00:17:08 +0200 Subject: [PATCH 188/197] 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 eefc4d85a69..322cd1e4d2b 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 35f028338c9..227a343a7a4 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 d3e49c3bd4c..6e3bbe6b1a8 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.5.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 cd1245c653c..caae5801b21 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 1ffb8cfd946..8112726607e 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 a3d4e900c57..c6f54b45f1e 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 bc0ba680fc0..85ba5c5d6ed 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 4b0e5e85478..cca04b5308c 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 35eb9a1379f..cca4094c37b 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 b068aed11ab..a80b9f48798 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 e1cd5b627adf52f2f50bcaf80af3a8417dd0b646 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Mon, 22 May 2023 02:14:52 -0600 Subject: [PATCH 189/197] 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 168899c38e0..f23a082df35 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 cca04b5308c..978541fcce4 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 cca4094c37b..fce363d0766 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 b59b995b404..14a1a0e1768 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 d1ee479e311119425b62f4154f223431d5f34520 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 190/197] 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 a43482e3e90..bea2836fead 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 8452ba2ed32..da144c398ed 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 978541fcce4..afbea5d99be 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2745,7 +2745,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 fce363d0766..aa19a3e141f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1982,7 +1982,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 dbbd9265d6d5c7342c7565f17a307ff272a02f8f Mon Sep 17 00:00:00 2001 From: Dominik Date: Mon, 22 May 2023 23:15:01 +0200 Subject: [PATCH 191/197] 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 b59fc390a6b..767a27ffdfd 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 afbea5d99be..35b38ad2c90 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -796,7 +796,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 aa19a3e141f..26a72cb2e2d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -615,7 +615,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 cc94a9f4bb8e821176bd13c087614d6e8edde793 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 May 2023 10:39:56 -0500 Subject: [PATCH 192/197] 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 f4e048ecf16..8be7d8dd2d1 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 2f163469bfa..0b7a42267d8 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 56965dff850..eeaa5f6c622 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 35b38ad2c90..7fb93f6db1f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2688,7 +2688,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 26a72cb2e2d..4008e64b8b8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1943,7 +1943,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 fce22750831fde4c490936fe6174337ffc801940 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 193/197] 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 85ba5c5d6ed..2f23efddbb7 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.1.7 janus==1.0.0 jinja2==3.1.2 diff --git a/pyproject.toml b/pyproject.toml index 2780f467729..0c5976ce15b 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.1.7", "jinja2==3.1.2", diff --git a/requirements.txt b/requirements.txt index 425e82d4311..b4bee14dec4 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.1.7 jinja2==3.1.2 From 0f888340daf8047712180302aba4d066cd21287f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 May 2023 11:56:27 -0500 Subject: [PATCH 194/197] 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 be6eba6793e..f51b1a2972b 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 41702410f7ed9058bee74b849869c7ad6c806684 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Tue, 23 May 2023 20:42:09 +0200 Subject: [PATCH 195/197] 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 a82614cbcc6..bd65b3a0925 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 10a52eb8805..ae2b7a68c3a 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 f90d8eb485d..c529ee12c5f 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 190bf33dcf7..5af01f2eea5 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 809d0ad7386..2eb3c22c1f7 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 7fb93f6db1f..87a98fdfe99 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2080,7 +2080,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 4008e64b8b8..5ae471a8401 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1497,7 +1497,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 2155f20fe3a..f5a6d5fd1e9 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 fa90ecff1d5..3500c73f790 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 2950a61622c..c0e1e898028 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 f295e3bf154..32dfd29e796 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 f7a9749325f..62162b3c2d7 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 ac2a840d041..e8c4603ab9c 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 3bbeb51151a..4e9efad2268 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 92153e61516..23dcf667c58 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 5ea56033a78..6289cb77da5 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 a541fdf4e77..cac06cbae20 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 f037ba80bc0..376426057af 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 fa6ed7afeff..3db9105562a 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 7be49b650b5..60335aa602d 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 dd23fbda2cc..426b6653623 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 5ab0d497278..9214b9511be 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 ef854112008..78ffa477b33 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 63b81d86ef9ac6c5fcc1b1227fd2d7f58ee44434 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 May 2023 14:47:31 -0500 Subject: [PATCH 196/197] 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 43915c0187b..67d3bff3b2a 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 264bed1af7ad516cd212a6fbb35e4b7fd81b993f Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 23 May 2023 21:54:57 +0200 Subject: [PATCH 197/197] Bumped version to 2023.5.4 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 05e9808473a..6bb2204ce26 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 = 5 -PATCH_VERSION: Final = "3" +PATCH_VERSION: Final = "4" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 10, 0) diff --git a/pyproject.toml b/pyproject.toml index 0c5976ce15b..023f67ac610 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.5.3" +version = "2023.5.4" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst"